From 2ebb03b8685e5038583fe33dab3fa2a1ef9a618e Mon Sep 17 00:00:00 2001 From: David Culbreth Date: Tue, 24 Mar 2026 14:45:07 -0500 Subject: [PATCH] first pass at access control setup --- .codex_write_test | 0 .gitignore | 1 + crates/api/src/auth/ldap.rs | 34 +- crates/api/src/auth/oidc.rs | 34 +- crates/api/src/authz.rs | 17 +- crates/api/src/dto/action.rs | 12 +- crates/api/src/dto/mod.rs | 7 +- crates/api/src/dto/permission.rs | 47 +- crates/api/src/dto/rule.rs | 12 +- crates/api/src/dto/trigger.rs | 10 +- crates/api/src/openapi.rs | 17 +- crates/api/src/routes/actions.rs | 2 +- crates/api/src/routes/artifacts.rs | 260 ++++++- crates/api/src/routes/auth.rs | 19 + crates/api/src/routes/events.rs | 12 +- crates/api/src/routes/executions.rs | 13 - crates/api/src/routes/keys.rs | 94 ++- crates/api/src/routes/permissions.rs | 418 ++++++++++- crates/api/src/routes/rules.rs | 4 +- crates/api/src/routes/triggers.rs | 2 +- crates/api/src/routes/webhooks.rs | 69 +- crates/api/src/routes/workflows.rs | 21 +- crates/api/src/validation/params.rs | 2 +- crates/api/tests/helpers.rs | 4 +- .../tests/rbac_scoped_resources_api_tests.rs | 276 +++++++ .../api/tests/sse_execution_stream_tests.rs | 2 +- crates/cli/src/commands/action.rs | 16 +- crates/cli/src/commands/rule.rs | 14 +- crates/common/src/models.rs | 26 +- crates/common/src/pack_registry/loader.rs | 28 +- crates/common/src/rbac.rs | 42 +- crates/common/src/repositories/action.rs | 9 +- crates/common/src/repositories/artifact.rs | 30 +- crates/common/src/repositories/identity.rs | 271 ++++++- crates/common/src/repositories/rule.rs | 13 +- crates/common/src/repositories/trigger.rs | 9 +- crates/common/src/workflow/registrar.rs | 8 +- .../common/tests/action_repository_tests.rs | 13 +- .../tests/enforcement_repository_tests.rs | 38 +- crates/common/tests/event_repository_tests.rs | 2 +- crates/common/tests/helpers.rs | 4 +- .../common/tests/identity_repository_tests.rs | 4 + crates/common/tests/rule_repository_tests.rs | 70 +- .../common/tests/sensor_repository_tests.rs | 24 +- crates/executor/src/enforcement_processor.rs | 2 +- .../tests/fifo_ordering_integration_test.rs | 2 +- .../executor/tests/policy_enforcer_tests.rs | 2 +- crates/worker/src/runtime/process.rs | 52 +- .../20250101000003_identity_and_auth.sql | 55 ++ ...250101000004_trigger_sensor_event_rule.sql | 4 +- ...0250101000005_execution_and_operations.sql | 2 +- packs/core/permission_sets/admin.yaml | 16 +- packs/core/permission_sets/editor.yaml | 10 +- packs/core/permission_sets/executor.yaml | 8 +- packs/core/permission_sets/viewer.yaml | 6 +- web/src/App.tsx | 21 + web/src/api/index.ts | 15 +- web/src/api/models/ActionResponse.ts | 121 ++- web/src/api/models/ActionSummary.ts | 89 ++- web/src/api/models/AgentArchInfo.ts | 22 + web/src/api/models/AgentBinaryInfo.ts | 19 + .../api/models/ApiResponse_ActionResponse.ts | 131 ++-- .../ApiResponse_AuthSettingsResponse.ts | 75 ++ ...ary.ts => ApiResponse_IdentityResponse.ts} | 7 +- .../api/models/ApiResponse_RuleResponse.ts | 147 ++-- .../api/models/ApiResponse_SensorResponse.ts | 131 ++-- web/src/api/models/CreateActionRequest.ts | 73 +- .../CreateIdentityRoleAssignmentRequest.ts | 8 + ...reatePermissionSetRoleAssignmentRequest.ts | 8 + web/src/api/models/CreateRuleRequest.ts | 81 +- web/src/api/models/CreateSensorRequest.ts | 81 +- web/src/api/models/IdentityResponse.ts | 17 + .../models/IdentityRoleAssignmentResponse.ts | 14 + web/src/api/models/IdentitySummary.ts | 2 + web/src/api/models/LdapLoginRequest.ts | 18 + web/src/api/models/PackDescriptionPatch.ts | 18 + .../models/PaginatedResponse_ActionSummary.ts | 101 ++- .../PaginatedResponse_IdentitySummary.ts | 2 + .../models/PaginatedResponse_RuleSummary.ts | 109 ++- .../models/PaginatedResponse_SensorSummary.ts | 85 ++- .../PermissionSetRoleAssignmentResponse.ts | 12 + web/src/api/models/PermissionSetSummary.ts | 2 + web/src/api/models/RuleResponse.ts | 137 ++-- web/src/api/models/RuleSummary.ts | 97 ++- .../models/RuntimeVersionConstraintPatch.ts | 19 + web/src/api/models/SensorResponse.ts | 121 ++- web/src/api/models/SensorSummary.ts | 73 +- web/src/api/models/TriggerStringPatch.ts | 18 + web/src/api/models/UpdateActionRequest.ts | 6 +- web/src/api/models/UpdateIdentityRequest.ts | 1 + web/src/api/models/UpdatePackRequest.ts | 6 +- web/src/api/models/UpdateTriggerRequest.ts | 6 +- web/src/api/services/AgentService.ts | 61 ++ web/src/api/services/AuthService.ts | 128 ++++ web/src/api/services/PermissionsService.ts | 256 +++++++ web/src/components/forms/PackForm.tsx | 6 +- web/src/components/forms/RuleForm.tsx | 510 ++++++------- .../forms/RuleMatchConditionsEditor.tsx | 507 +++++++++++++ web/src/components/forms/TriggerForm.tsx | 19 +- web/src/components/layout/MainLayout.tsx | 62 +- web/src/components/layout/navIcons.tsx | 31 + web/src/hooks/usePermissions.ts | 243 ++++++ .../access-control/AccessControlPage.tsx | 707 ++++++++++++++++++ .../access-control/IdentityDetailPage.tsx | 368 +++++++++ .../PermissionSetDetailPage.tsx | 619 +++++++++++++++ 105 files changed, 6163 insertions(+), 1416 deletions(-) create mode 100644 .codex_write_test create mode 100644 crates/api/tests/rbac_scoped_resources_api_tests.rs create mode 100644 web/src/api/models/AgentArchInfo.ts create mode 100644 web/src/api/models/AgentBinaryInfo.ts create mode 100644 web/src/api/models/ApiResponse_AuthSettingsResponse.ts rename web/src/api/models/{ApiResponse_IdentitySummary.ts => ApiResponse_IdentityResponse.ts} (53%) create mode 100644 web/src/api/models/CreateIdentityRoleAssignmentRequest.ts create mode 100644 web/src/api/models/CreatePermissionSetRoleAssignmentRequest.ts create mode 100644 web/src/api/models/IdentityResponse.ts create mode 100644 web/src/api/models/IdentityRoleAssignmentResponse.ts create mode 100644 web/src/api/models/LdapLoginRequest.ts create mode 100644 web/src/api/models/PackDescriptionPatch.ts create mode 100644 web/src/api/models/PermissionSetRoleAssignmentResponse.ts create mode 100644 web/src/api/models/RuntimeVersionConstraintPatch.ts create mode 100644 web/src/api/models/TriggerStringPatch.ts create mode 100644 web/src/api/services/AgentService.ts create mode 100644 web/src/components/forms/RuleMatchConditionsEditor.tsx create mode 100644 web/src/components/layout/navIcons.tsx create mode 100644 web/src/hooks/usePermissions.ts create mode 100644 web/src/pages/access-control/AccessControlPage.tsx create mode 100644 web/src/pages/access-control/IdentityDetailPage.tsx create mode 100644 web/src/pages/access-control/PermissionSetDetailPage.tsx diff --git a/.codex_write_test b/.codex_write_test new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 3381f79..b65eea9 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,5 @@ docker-compose.override.yml *.pid packs.examples/ +packs.external/ codex/ diff --git a/crates/api/src/auth/ldap.rs b/crates/api/src/auth/ldap.rs index 612b89f..63cfde3 100644 --- a/crates/api/src/auth/ldap.rs +++ b/crates/api/src/auth/ldap.rs @@ -3,7 +3,10 @@ use attune_common::{ config::LdapConfig, repositories::{ - identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, + identity::{ + CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository, + UpdateIdentityInput, + }, Create, Update, }, }; @@ -63,6 +66,11 @@ pub async fn authenticate( // Upsert identity in DB and issue JWT tokens 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 refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; @@ -351,10 +359,13 @@ async fn upsert_identity( display_name, password_hash: None, attributes: Some(attributes), + frozen: None, }; - IdentityRepository::update(&state.db, identity.id, updated) + let identity = IdentityRepository::update(&state.db, identity.id, updated) .await - .map_err(Into::into) + .map_err(ApiError::from)?; + sync_roles(&state.db, identity.id, "ldap", &claims.groups).await?; + Ok(identity) } None => { // Avoid login collisions @@ -363,7 +374,7 @@ async fn upsert_identity( None => desired_login, }; - IdentityRepository::create( + let identity = IdentityRepository::create( &state.db, CreateIdentityInput { login, @@ -373,11 +384,24 @@ async fn upsert_identity( }, ) .await - .map_err(Into::into) + .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 + .map_err(Into::into) +} + /// Derive the login name from LDAP claims. fn derive_login(claims: &LdapUserClaims) -> String { claims diff --git a/crates/api/src/auth/oidc.rs b/crates/api/src/auth/oidc.rs index 4faf822..c4a9da3 100644 --- a/crates/api/src/auth/oidc.rs +++ b/crates/api/src/auth/oidc.rs @@ -3,7 +3,10 @@ use attune_common::{ config::OidcConfig, repositories::{ - identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, + identity::{ + CreateIdentityInput, IdentityRepository, IdentityRoleAssignmentRepository, + UpdateIdentityInput, + }, Create, Update, }, }; @@ -282,6 +285,11 @@ pub async fn handle_callback( } 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 refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; @@ -511,10 +519,13 @@ async fn upsert_identity( display_name, password_hash: None, attributes: Some(attributes.clone()), + frozen: None, }; - IdentityRepository::update(&state.db, identity.id, updated) + let identity = IdentityRepository::update(&state.db, identity.id, updated) .await - .map_err(Into::into) + .map_err(ApiError::from)?; + sync_roles(&state.db, identity.id, "oidc", &oidc_claims.groups).await?; + Ok(identity) } None => { let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? { @@ -522,7 +533,7 @@ async fn upsert_identity( None => desired_login, }; - IdentityRepository::create( + let identity = IdentityRepository::create( &state.db, CreateIdentityInput { login, @@ -532,11 +543,24 @@ async fn upsert_identity( }, ) .await - .map_err(Into::into) + .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 + .map_err(Into::into) +} + fn derive_login(oidc_claims: &OidcIdentityClaims) -> String { oidc_claims .email diff --git a/crates/api/src/authz.rs b/crates/api/src/authz.rs index 8eac627..10ef9a3 100644 --- a/crates/api/src/authz.rs +++ b/crates/api/src/authz.rs @@ -10,7 +10,7 @@ use crate::{ use attune_common::{ rbac::{Action, AuthorizationContext, Grant, Resource}, repositories::{ - identity::{IdentityRepository, PermissionSetRepository}, + identity::{IdentityRepository, IdentityRoleAssignmentRepository, PermissionSetRepository}, FindById, }, }; @@ -95,8 +95,16 @@ impl AuthorizationService { } async fn load_effective_grants(&self, identity_id: i64) -> Result, ApiError> { - let permission_sets = + let mut permission_sets = 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(); for permission_set in permission_sets { @@ -126,10 +134,6 @@ fn resource_name(resource: Resource) -> &'static str { Resource::Inquiries => "inquiries", Resource::Keys => "keys", Resource::Artifacts => "artifacts", - Resource::Workflows => "workflows", - Resource::Webhooks => "webhooks", - Resource::Analytics => "analytics", - Resource::History => "history", Resource::Identities => "identities", Resource::Permissions => "permissions", } @@ -145,5 +149,6 @@ fn action_name(action: Action) -> &'static str { Action::Cancel => "cancel", Action::Respond => "respond", Action::Manage => "manage", + Action::Decrypt => "decrypt", } } diff --git a/crates/api/src/dto/action.rs b/crates/api/src/dto/action.rs index 1f23a45..0af6dd5 100644 --- a/crates/api/src/dto/action.rs +++ b/crates/api/src/dto/action.rs @@ -25,9 +25,8 @@ pub struct CreateActionRequest { pub label: String, /// Action description - #[validate(length(min = 1))] #[schema(example = "Posts a message to a Slack channel")] - pub description: String, + pub description: Option, /// Entry point for action execution (e.g., path to script, function name) #[validate(length(min = 1, max = 1024))] @@ -63,7 +62,6 @@ pub struct UpdateActionRequest { pub label: Option, /// Action description - #[validate(length(min = 1))] #[schema(example = "Posts a message to a Slack channel with enhanced features")] pub description: Option, @@ -121,7 +119,7 @@ pub struct ActionResponse { /// Action description #[schema(example = "Posts a message to a Slack channel")] - pub description: String, + pub description: Option, /// Entry point #[schema(example = "/actions/slack/post_message.py")] @@ -183,7 +181,7 @@ pub struct ActionSummary { /// Action description #[schema(example = "Posts a message to a Slack channel")] - pub description: String, + pub description: Option, /// Entry point #[schema(example = "/actions/slack/post_message.py")] @@ -321,7 +319,7 @@ mod tests { r#ref: "".to_string(), // Invalid: empty pack_ref: "test-pack".to_string(), label: "Test Action".to_string(), - description: "Test description".to_string(), + description: Some("Test description".to_string()), entrypoint: "/actions/test.py".to_string(), runtime: None, runtime_version_constraint: None, @@ -338,7 +336,7 @@ mod tests { r#ref: "test.action".to_string(), pack_ref: "test-pack".to_string(), label: "Test Action".to_string(), - description: "Test description".to_string(), + description: Some("Test description".to_string()), entrypoint: "/actions/test.py".to_string(), runtime: None, runtime_version_constraint: None, diff --git a/crates/api/src/dto/mod.rs b/crates/api/src/dto/mod.rs index 4f3572d..f4de6f8 100644 --- a/crates/api/src/dto/mod.rs +++ b/crates/api/src/dto/mod.rs @@ -51,9 +51,10 @@ pub use inquiry::{ pub use key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest}; pub use pack::{CreatePackRequest, PackResponse, PackSummary, UpdatePackRequest}; pub use permission::{ - CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, IdentitySummary, - PermissionAssignmentResponse, PermissionSetQueryParams, PermissionSetSummary, - UpdateIdentityRequest, + CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, CreatePermissionAssignmentRequest, + CreatePermissionSetRoleAssignmentRequest, IdentityResponse, IdentityRoleAssignmentResponse, + IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams, + PermissionSetRoleAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest, }; pub use rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}; pub use runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest}; diff --git a/crates/api/src/dto/permission.rs b/crates/api/src/dto/permission.rs index ce12db5..bf4f731 100644 --- a/crates/api/src/dto/permission.rs +++ b/crates/api/src/dto/permission.rs @@ -14,10 +14,32 @@ pub struct IdentitySummary { pub id: i64, pub login: String, pub display_name: Option, + pub frozen: bool, pub attributes: JsonValue, + pub roles: Vec, } -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, + pub updated: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct IdentityResponse { + pub id: i64, + pub login: String, + pub display_name: Option, + pub frozen: bool, + pub attributes: JsonValue, + pub roles: Vec, + pub direct_permissions: Vec, +} #[derive(Debug, Clone, Serialize, ToSchema)] pub struct PermissionSetSummary { @@ -27,6 +49,7 @@ pub struct PermissionSetSummary { pub label: Option, pub description: Option, pub grants: JsonValue, + pub roles: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -38,6 +61,15 @@ pub struct PermissionAssignmentResponse { pub created: chrono::DateTime, } +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PermissionSetRoleAssignmentResponse { + pub id: i64, + pub permission_set_id: i64, + pub permission_set_ref: Option, + pub role: String, + pub created: chrono::DateTime, +} + #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreatePermissionAssignmentRequest { pub identity_id: Option, @@ -45,6 +77,18 @@ pub struct CreatePermissionAssignmentRequest { 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)] pub struct CreateIdentityRequest { #[validate(length(min = 3, max = 255))] @@ -62,4 +106,5 @@ pub struct UpdateIdentityRequest { pub display_name: Option, pub password: Option, pub attributes: Option, + pub frozen: Option, } diff --git a/crates/api/src/dto/rule.rs b/crates/api/src/dto/rule.rs index 06d2958..ad5193e 100644 --- a/crates/api/src/dto/rule.rs +++ b/crates/api/src/dto/rule.rs @@ -25,9 +25,8 @@ pub struct CreateRuleRequest { pub label: String, /// Rule description - #[validate(length(min = 1))] #[schema(example = "Send Slack notification when an error occurs")] - pub description: String, + pub description: Option, /// Action reference to execute when rule matches #[validate(length(min = 1, max = 255))] @@ -69,7 +68,6 @@ pub struct UpdateRuleRequest { pub label: Option, /// Rule description - #[validate(length(min = 1))] #[schema(example = "Enhanced error notification with filtering")] pub description: Option, @@ -115,7 +113,7 @@ pub struct RuleResponse { /// Rule description #[schema(example = "Send Slack notification when an error occurs")] - pub description: String, + pub description: Option, /// Action ID (null if the referenced action has been deleted) #[schema(example = 1)] @@ -183,7 +181,7 @@ pub struct RuleSummary { /// Rule description #[schema(example = "Send Slack notification when an error occurs")] - pub description: String, + pub description: Option, /// Action reference #[schema(example = "slack.post_message")] @@ -297,7 +295,7 @@ mod tests { r#ref: "".to_string(), // Invalid: empty pack_ref: "test-pack".to_string(), label: "Test Rule".to_string(), - description: "Test description".to_string(), + description: Some("Test description".to_string()), action_ref: "test.action".to_string(), trigger_ref: "test.trigger".to_string(), conditions: default_empty_object(), @@ -315,7 +313,7 @@ mod tests { r#ref: "test.rule".to_string(), pack_ref: "test-pack".to_string(), label: "Test Rule".to_string(), - description: "Test description".to_string(), + description: Some("Test description".to_string()), action_ref: "test.action".to_string(), trigger_ref: "test.trigger".to_string(), conditions: serde_json::json!({ diff --git a/crates/api/src/dto/trigger.rs b/crates/api/src/dto/trigger.rs index 4653703..a90fdea 100644 --- a/crates/api/src/dto/trigger.rs +++ b/crates/api/src/dto/trigger.rs @@ -203,9 +203,8 @@ pub struct CreateSensorRequest { pub label: String, /// Sensor description - #[validate(length(min = 1))] #[schema(example = "Monitors CPU usage and generates events")] - pub description: String, + pub description: Option, /// Entry point for sensor execution (e.g., path to script, function name) #[validate(length(min = 1, max = 1024))] @@ -247,7 +246,6 @@ pub struct UpdateSensorRequest { pub label: Option, /// Sensor description - #[validate(length(min = 1))] #[schema(example = "Enhanced CPU monitoring with alerts")] pub description: Option, @@ -297,7 +295,7 @@ pub struct SensorResponse { /// Sensor description #[schema(example = "Monitors CPU usage and generates events")] - pub description: String, + pub description: Option, /// Entry point #[schema(example = "/sensors/monitoring/cpu_monitor.py")] @@ -357,7 +355,7 @@ pub struct SensorSummary { /// Sensor description #[schema(example = "Monitors CPU usage and generates events")] - pub description: String, + pub description: Option, /// Trigger reference #[schema(example = "monitoring.cpu_threshold")] @@ -499,7 +497,7 @@ mod tests { r#ref: "test.sensor".to_string(), pack_ref: "test-pack".to_string(), label: "Test Sensor".to_string(), - description: "Test description".to_string(), + description: Some("Test description".to_string()), entrypoint: "/sensors/test.py".to_string(), runtime_ref: "python3".to_string(), trigger_ref: "test.trigger".to_string(), diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index ce1da31..18a4c24 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -27,8 +27,11 @@ use crate::dto::{ UpdatePackRequest, WorkflowSyncResult, }, permission::{ - CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, - IdentitySummary, PermissionAssignmentResponse, PermissionSetSummary, UpdateIdentityRequest, + CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, + CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest, + IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary, + PermissionAssignmentResponse, PermissionSetRoleAssignmentResponse, PermissionSetSummary, + UpdateIdentityRequest, }, rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest}, runtime::{CreateRuntimeRequest, RuntimeResponse, RuntimeSummary, UpdateRuntimeRequest}, @@ -185,6 +188,12 @@ use crate::dto::{ crate::routes::permissions::list_identity_permissions, crate::routes::permissions::create_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 crate::routes::workflows::list_workflows, @@ -277,6 +286,10 @@ use crate::dto::{ PermissionSetSummary, PermissionAssignmentResponse, CreatePermissionAssignmentRequest, + CreateIdentityRoleAssignmentRequest, + IdentityRoleAssignmentResponse, + CreatePermissionSetRoleAssignmentRequest, + PermissionSetRoleAssignmentResponse, // Runtime DTOs CreateRuntimeRequest, diff --git a/crates/api/src/routes/actions.rs b/crates/api/src/routes/actions.rs index 03a1564..f47681d 100644 --- a/crates/api/src/routes/actions.rs +++ b/crates/api/src/routes/actions.rs @@ -277,7 +277,7 @@ pub async fn update_action( // Create update input let update_input = UpdateActionInput { label: request.label, - description: request.description, + description: request.description.map(Patch::Set), entrypoint: request.entrypoint, runtime: request.runtime, runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch { diff --git a/crates/api/src/routes/artifacts.rs b/crates/api/src/routes/artifacts.rs index 06c59ed..2e2cb9f 100644 --- a/crates/api/src/routes/artifacts.rs +++ b/crates/api/src/routes/artifacts.rs @@ -40,7 +40,8 @@ use attune_common::repositories::{ }; use crate::{ - auth::middleware::RequireAuth, + auth::{jwt::TokenType, middleware::AuthenticatedUser, middleware::RequireAuth}, + authz::{AuthorizationCheck, AuthorizationService}, dto::{ artifact::{ AllocateFileVersionByRefRequest, AppendProgressRequest, ArtifactExecutionPatch, @@ -55,6 +56,7 @@ use crate::{ middleware::{ApiError, ApiResult}, state::AppState, }; +use attune_common::rbac::{Action, AuthorizationContext, Resource}; // ============================================================================ // Artifact CRUD @@ -72,7 +74,7 @@ use crate::{ security(("bearer_auth" = [])) )] pub async fn list_artifacts( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Query(query): Query, ) -> ApiResult { @@ -88,8 +90,16 @@ pub async fn list_artifacts( }; let result = ArtifactRepository::search(&state.db, &filters).await?; + let mut rows = result.rows; - let items: Vec = 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 = rows.into_iter().map(ArtifactSummary::from).collect(); let pagination = PaginationParams { page: query.page, @@ -113,7 +123,7 @@ pub async fn list_artifacts( security(("bearer_auth" = [])) )] pub async fn get_artifact( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { @@ -121,6 +131,10 @@ pub async fn get_artifact( .await? .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(( StatusCode::OK, Json(ApiResponse::new(ArtifactResponse::from(artifact))), @@ -140,7 +154,7 @@ pub async fn get_artifact( security(("bearer_auth" = [])) )] pub async fn get_artifact_by_ref( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(artifact_ref): Path, ) -> ApiResult { @@ -148,6 +162,10 @@ pub async fn get_artifact_by_ref( .await? .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(( StatusCode::OK, Json(ApiResponse::new(ArtifactResponse::from(artifact))), @@ -168,7 +186,7 @@ pub async fn get_artifact_by_ref( security(("bearer_auth" = [])) )] pub async fn create_artifact( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Json(request): Json, ) -> ApiResult { @@ -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 { r#ref: request.r#ref, scope: request.scope, @@ -240,16 +268,18 @@ pub async fn create_artifact( security(("bearer_auth" = [])) )] pub async fn update_artifact( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, ) -> ApiResult { // Verify artifact exists - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; + authorize_artifact_action(&state, &user, Action::Update, &artifact).await?; + let input = UpdateArtifactInput { r#ref: None, // Ref is immutable after creation scope: request.scope, @@ -305,7 +335,7 @@ pub async fn update_artifact( security(("bearer_auth" = [])) )] pub async fn delete_artifact( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { @@ -313,6 +343,8 @@ pub async fn delete_artifact( .await? .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 let file_versions = ArtifactVersionRepository::find_file_versions_by_artifact(&state.db, id).await?; @@ -355,11 +387,17 @@ pub async fn delete_artifact( security(("bearer_auth" = [])) )] pub async fn list_artifacts_by_execution( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(execution_id): Path, ) -> ApiResult { - 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 = artifacts.into_iter().map(ArtifactSummary::from).collect(); Ok((StatusCode::OK, Json(ApiResponse::new(items)))) @@ -387,7 +425,7 @@ pub async fn list_artifacts_by_execution( security(("bearer_auth" = [])) )] pub async fn append_progress( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, @@ -396,6 +434,8 @@ pub async fn append_progress( .await? .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 { return Err(ApiError::BadRequest(format!( "Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.", @@ -430,16 +470,18 @@ pub async fn append_progress( security(("bearer_auth" = [])) )] pub async fn set_artifact_data( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, ) -> ApiResult { // Verify exists - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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?; Ok(( @@ -468,15 +510,19 @@ pub async fn set_artifact_data( security(("bearer_auth" = [])) )] pub async fn list_versions( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { // Verify artifact exists - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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 items: Vec = versions .into_iter() @@ -502,15 +548,19 @@ pub async fn list_versions( security(("bearer_auth" = [])) )] pub async fn get_version( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path((id, version)): Path<(i64, i32)>, ) -> ApiResult { // Verify artifact exists - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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) .await? .ok_or_else(|| { @@ -536,14 +586,18 @@ pub async fn get_version( security(("bearer_auth" = [])) )] pub async fn get_latest_version( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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) .await? .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" = [])) )] pub async fn create_version_json( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, ) -> ApiResult { - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?; + authorize_artifact_action(&state, &user, Action::Update, &artifact).await?; + let input = CreateArtifactVersionInput { artifact: id, content_type: Some( @@ -624,7 +680,7 @@ pub async fn create_version_json( security(("bearer_auth" = [])) )] pub async fn create_version_file( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, @@ -633,6 +689,8 @@ pub async fn create_version_file( .await? .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 if !is_file_backed_type(artifact.r#type) { return Err(ApiError::BadRequest(format!( @@ -726,15 +784,17 @@ pub async fn create_version_file( security(("bearer_auth" = [])) )] pub async fn upload_version( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, mut multipart: Multipart, ) -> ApiResult { - ArtifactRepository::find_by_id(&state.db, id) + let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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> = None; let mut content_type: Option = None; let mut meta: Option = None; @@ -854,7 +914,7 @@ pub async fn upload_version( security(("bearer_auth" = [])) )] pub async fn download_version( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path((id, version)): Path<(i64, i32)>, ) -> ApiResult { @@ -862,6 +922,10 @@ pub async fn download_version( .await? .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 let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) .await? @@ -904,7 +968,7 @@ pub async fn download_version( security(("bearer_auth" = [])) )] pub async fn download_latest( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { @@ -912,6 +976,10 @@ pub async fn download_latest( .await? .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 let ver = ArtifactVersionRepository::find_latest(&state.db, id) .await? @@ -955,7 +1023,7 @@ pub async fn download_latest( security(("bearer_auth" = [])) )] pub async fn delete_version( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path((id, version)): Path<(i64, i32)>, ) -> ApiResult { @@ -964,6 +1032,8 @@ pub async fn delete_version( .await? .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 let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version) .await? @@ -1042,7 +1112,7 @@ pub async fn delete_version( security(("bearer_auth" = [])) )] pub async fn upload_version_by_ref( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(artifact_ref): Path, mut multipart: Multipart, @@ -1157,6 +1227,8 @@ pub async fn upload_version_by_ref( // Upsert: find existing artifact or create a new one let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { Some(existing) => { + authorize_artifact_action(&state, &user, Action::Update, &existing).await?; + // Update execution link if a new execution ID was provided if execution_id.is_some() && execution_id != existing.execution { 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 let a_retention_policy: RetentionPolicyType = match &retention_policy { Some(rp) if !rp.is_empty() => { @@ -1297,7 +1379,7 @@ pub async fn upload_version_by_ref( security(("bearer_auth" = [])) )] pub async fn allocate_file_version_by_ref( - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, State(state): State>, Path(artifact_ref): Path, Json(request): Json, @@ -1305,6 +1387,8 @@ pub async fn allocate_file_version_by_ref( // Upsert: find existing artifact or create a new one let artifact = match ArtifactRepository::find_by_ref(&state.db, &artifact_ref).await? { Some(existing) => { + authorize_artifact_action(&state, &user, Action::Update, &existing).await?; + // Update execution link if a new execution ID was provided if request.execution.is_some() && request.execution != existing.execution { let update_input = UpdateArtifactInput { @@ -1347,6 +1431,16 @@ pub async fn allocate_file_version_by_ref( .unwrap_or(RetentionPolicyType::Versions); 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 { r#ref: artifact_ref.clone(), scope: a_scope, @@ -1437,6 +1531,105 @@ pub async fn allocate_file_version_by_ref( // Helpers // ============================================================================ +async fn authorize_artifact_action( + state: &Arc, + 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, + 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, + user: &AuthenticatedUser, +) -> Result)>, 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. fn is_file_backed_type(artifact_type: ArtifactType) -> bool { matches!( @@ -1775,14 +1968,19 @@ pub async fn stream_artifact( let token = params.token.as_ref().ok_or(ApiError::Unauthorized( "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()))?; + let user = AuthenticatedUser { claims }; // --- resolve artifact + latest version --------------------------------- let artifact = ArtifactRepository::find_by_id(&state.db, id) .await? .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) { return Err(ApiError::BadRequest(format!( "Artifact '{}' is type {:?} which is not file-backed. \ diff --git a/crates/api/src/routes/auth.rs b/crates/api/src/routes/auth.rs index 0c40615..797db59 100644 --- a/crates/api/src/routes/auth.rs +++ b/crates/api/src/routes/auth.rs @@ -169,6 +169,12 @@ pub async fn login( .await? .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 let password_hash = identity .password_hash @@ -324,6 +330,12 @@ pub async fn refresh_token( .await? .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 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)?; @@ -380,6 +392,12 @@ pub async fn get_current_user( .await? .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 { id: identity.id, login: identity.login, @@ -551,6 +569,7 @@ pub async fn change_password( display_name: None, password_hash: Some(new_password_hash), attributes: None, + frozen: None, }; IdentityRepository::update(&state.db, identity_id, update_input).await?; diff --git a/crates/api/src/routes/events.rs b/crates/api/src/routes/events.rs index 60c026e..7278781 100644 --- a/crates/api/src/routes/events.rs +++ b/crates/api/src/routes/events.rs @@ -82,6 +82,17 @@ pub async fn create_event( State(state): State>, Json(payload): Json, ) -> ApiResult { + // 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 payload .validate() @@ -128,7 +139,6 @@ pub async fn create_event( }; // 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 { TokenType::Sensor => { // Extract sensor reference from login diff --git a/crates/api/src/routes/executions.rs b/crates/api/src/routes/executions.rs index aba9c42..610f1bd 100644 --- a/crates/api/src/routes/executions.rs +++ b/crates/api/src/routes/executions.rs @@ -93,19 +93,6 @@ pub async fn create_execution( }, ) .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 diff --git a/crates/api/src/routes/keys.rs b/crates/api/src/routes/keys.rs index 7cf2ec9..69a358c 100644 --- a/crates/api/src/routes/keys.rs +++ b/crates/api/src/routes/keys.rs @@ -120,12 +120,16 @@ pub async fn get_key( .await? .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 .0 .identity_id() .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?; let authz = AuthorizationService::new(state.db.clone()); + + // Basic read check — hide behind 404 to prevent enumeration. authz .authorize( &user.0, @@ -136,28 +140,55 @@ pub async fn get_key( }, ) .await - // Hide unauthorized records behind 404 to reduce enumeration leakage. .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 { + 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 { - let encryption_key = state - .config - .security - .encryption_key - .as_ref() - .ok_or_else(|| { - ApiError::InternalServerError("Encryption key not configured on server".to_string()) - })?; + if can_decrypt { + let encryption_key = + state + .config + .security + .encryption_key + .as_ref() + .ok_or_else(|| { + ApiError::InternalServerError( + "Encryption key not configured on server".to_string(), + ) + })?; - let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key) - .map_err(|e| { + let decrypted_value = attune_common::crypto::decrypt_json(&key.value, encryption_key) + .map_err(|e| { tracing::error!("Failed to decrypt key '{}': {}", key_ref, e); ApiError::InternalServerError(format!("Failed to decrypt key: {}", e)) })?; - key.value = decrypted_value; + key.value = decrypted_value; + } else { + key.value = serde_json::Value::Null; + } } let response = ApiResponse::new(KeyResponse::from(key)); @@ -195,6 +226,7 @@ pub async fn create_key( let mut ctx = AuthorizationContext::new(identity_id); ctx.owner_identity_id = request.owner_identity; ctx.owner_type = Some(request.owner_type); + ctx.owner_ref = requested_key_owner_ref(&request); ctx.encrypted = Some(request.encrypted); 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.owner_identity_id = key.owner_identity; 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 } + +fn requested_key_owner_ref(request: &CreateKeyRequest) -> Option { + 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 { + 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), + } +} diff --git a/crates/api/src/routes/permissions.rs b/crates/api/src/routes/permissions.rs index ebdd8d4..0d6405e 100644 --- a/crates/api/src/routes/permissions.rs +++ b/crates/api/src/routes/permissions.rs @@ -9,12 +9,14 @@ use std::sync::Arc; use validator::Validate; use attune_common::{ - models::identity::{Identity, PermissionSet}, + models::identity::{Identity, IdentityRoleAssignment}, rbac::{Action, AuthorizationContext, Resource}, repositories::{ identity::{ - CreateIdentityInput, CreatePermissionAssignmentInput, IdentityRepository, - PermissionAssignmentRepository, PermissionSetRepository, UpdateIdentityInput, + CreateIdentityInput, CreateIdentityRoleAssignmentInput, + CreatePermissionAssignmentInput, CreatePermissionSetRoleAssignmentInput, + IdentityRepository, IdentityRoleAssignmentRepository, PermissionAssignmentRepository, + PermissionSetRepository, PermissionSetRoleAssignmentRepository, UpdateIdentityInput, }, Create, Delete, FindById, FindByRef, List, Update, }, @@ -26,9 +28,12 @@ use crate::{ authz::{AuthorizationCheck, AuthorizationService}, dto::{ common::{PaginatedResponse, PaginationParams}, - ApiResponse, CreateIdentityRequest, CreatePermissionAssignmentRequest, IdentityResponse, - IdentitySummary, PermissionAssignmentResponse, PermissionSetQueryParams, - PermissionSetSummary, SuccessResponse, UpdateIdentityRequest, + ApiResponse, CreateIdentityRequest, CreateIdentityRoleAssignmentRequest, + CreatePermissionAssignmentRequest, CreatePermissionSetRoleAssignmentRequest, + IdentityResponse, IdentityRoleAssignmentResponse, IdentitySummary, + PermissionAssignmentResponse, PermissionSetQueryParams, + PermissionSetRoleAssignmentResponse, PermissionSetSummary, SuccessResponse, + UpdateIdentityRequest, }, middleware::{ApiError, ApiResult}, state::AppState, @@ -58,16 +63,22 @@ pub async fn list_identities( let page_items = if start >= identities.len() { Vec::new() } else { - identities[start..end] - .iter() - .cloned() - .map(IdentitySummary::from) - .collect() + identities[start..end].to_vec() }; + 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(( 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) .await? .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::>(); 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, password_hash, attributes: request.attributes, + frozen: request.frozen, }, ) .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())); } - let response: Vec = permission_sets - .into_iter() - .map(PermissionSetSummary::from) - .collect(); + 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() + .map(|assignment| PermissionSetRoleAssignmentResponse { + 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))) } @@ -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)), + (status = 404, description = "Identity not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_identity_role_assignment( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(identity_id): Path, + Json(request): Json, +) -> ApiResult { + 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)), + (status = 404, description = "Identity role assignment not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_identity_role_assignment( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(assignment_id): Path, +) -> ApiResult { + 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)), + (status = 404, description = "Permission set not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn create_permission_set_role_assignment( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(permission_set_id): Path, + Json(request): Json, +) -> ApiResult { + 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)), + (status = 404, description = "Permission set role assignment not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_permission_set_role_assignment( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(assignment_id): Path, +) -> ApiResult { + 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)), + (status = 404, description = "Identity not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn freeze_identity( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(identity_id): Path, +) -> ApiResult { + 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)), + (status = 404, description = "Identity not found") + ), + security(("bearer_auth" = [])) +)] +pub async fn unfreeze_identity( + State(state): State>, + RequireAuth(user): RequireAuth, + Path(identity_id): Path, +) -> ApiResult { + set_identity_frozen(&state, &user, identity_id, false).await +} + pub fn routes() -> Router> { Router::new() .route("/identities", get(list_identities).post(create_identity)) @@ -421,11 +711,29 @@ pub fn routes() -> Router> { .put(update_identity) .delete(delete_identity), ) + .route( + "/identities/{id}/roles", + post(create_identity_role_assignment), + ) .route( "/identities/{id}/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/{id}/roles", + post(create_permission_set_role_assignment), + ) + .route( + "/permissions/sets/roles/{id}", + delete(delete_permission_set_role_assignment), + ) .route( "/permissions/assignments", post(create_permission_assignment), @@ -488,20 +796,82 @@ impl From for IdentitySummary { id: value.id, login: value.login, display_name: value.display_name, + frozen: value.frozen, attributes: value.attributes, + roles: Vec::new(), } } } -impl From for PermissionSetSummary { - fn from(value: PermissionSet) -> Self { +impl From for IdentityRoleAssignmentResponse { + fn from(value: IdentityRoleAssignment) -> Self { Self { id: value.id, - r#ref: value.r#ref, - pack_ref: value.pack_ref, - label: value.label, - description: value.description, - grants: value.grants, + identity_id: value.identity, + role: value.role, + source: value.source, + managed: value.managed, + created: value.created, + updated: value.updated, } } } + +impl From 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, + user: &crate::auth::middleware::AuthenticatedUser, + identity_id: i64, + frozen: bool, +) -> ApiResult { + 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))), + )) +} diff --git a/crates/api/src/routes/rules.rs b/crates/api/src/routes/rules.rs index 8e96a22..a216e29 100644 --- a/crates/api/src/routes/rules.rs +++ b/crates/api/src/routes/rules.rs @@ -20,7 +20,7 @@ use attune_common::repositories::{ pack::PackRepository, rule::{CreateRuleInput, RuleRepository, RuleSearchFilters, UpdateRuleInput}, trigger::TriggerRepository, - Create, Delete, FindByRef, Update, + Create, Delete, FindByRef, Patch, Update, }; use crate::{ @@ -474,7 +474,7 @@ pub async fn update_rule( // Create update input let update_input = UpdateRuleInput { label: request.label, - description: request.description, + description: request.description.map(Patch::Set), conditions: request.conditions, action_params: request.action_params, trigger_params: request.trigger_params, diff --git a/crates/api/src/routes/triggers.rs b/crates/api/src/routes/triggers.rs index 0b67309..a2014ad 100644 --- a/crates/api/src/routes/triggers.rs +++ b/crates/api/src/routes/triggers.rs @@ -724,7 +724,7 @@ pub async fn update_sensor( // Create update input let update_input = UpdateSensorInput { label: request.label, - description: request.description, + description: request.description.map(Patch::Set), entrypoint: request.entrypoint, runtime: None, runtime_ref: None, diff --git a/crates/api/src/routes/webhooks.rs b/crates/api/src/routes/webhooks.rs index e318a9e..33e4e79 100644 --- a/crates/api/src/routes/webhooks.rs +++ b/crates/api/src/routes/webhooks.rs @@ -20,8 +20,11 @@ use attune_common::{ }, }; +use attune_common::rbac::{Action, AuthorizationContext, Resource}; + use crate::{ auth::middleware::RequireAuth, + authz::{AuthorizationCheck, AuthorizationService}, dto::{ trigger::TriggerResponse, webhook::{WebhookReceiverRequest, WebhookReceiverResponse}, @@ -170,7 +173,7 @@ fn get_webhook_config_array( )] pub async fn enable_webhook( State(state): State>, - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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()))? .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 let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id) .await @@ -213,7 +236,7 @@ pub async fn enable_webhook( )] pub async fn disable_webhook( State(state): State>, - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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()))? .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 TriggerRepository::disable_webhook(&state.db, trigger.id) .await @@ -257,7 +300,7 @@ pub async fn disable_webhook( )] pub async fn regenerate_webhook_key( State(state): State>, - RequireAuth(_user): RequireAuth, + RequireAuth(user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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()))? .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 if !trigger.webhook_enabled { return Err(ApiError::BadRequest( diff --git a/crates/api/src/routes/workflows.rs b/crates/api/src/routes/workflows.rs index 9a0d7ba..9763eec 100644 --- a/crates/api/src/routes/workflows.rs +++ b/crates/api/src/routes/workflows.rs @@ -18,7 +18,7 @@ use attune_common::repositories::{ CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository, WorkflowSearchFilters, }, - Create, Delete, FindByRef, Update, + Create, Delete, FindByRef, Patch, Update, }; use crate::{ @@ -217,7 +217,7 @@ pub async fn create_workflow( pack.id, &pack.r#ref, &request.label, - &request.description.clone().unwrap_or_default(), + request.description.as_deref(), "workflow", request.param_schema.as_ref(), request.out_schema.as_ref(), @@ -416,7 +416,7 @@ pub async fn save_workflow_file( pack.id, &pack.r#ref, &request.label, - &request.description.clone().unwrap_or_default(), + request.description.as_deref(), &entrypoint, request.param_schema.as_ref(), request.out_schema.as_ref(), @@ -499,7 +499,7 @@ pub async fn update_workflow_file( pack.id, &pack.r#ref, &request.label, - &request.description.unwrap_or_default(), + request.description.as_deref(), &entrypoint, request.param_schema.as_ref(), request.out_schema.as_ref(), @@ -702,7 +702,7 @@ async fn create_companion_action( pack_id: i64, pack_ref: &str, label: &str, - description: &str, + description: Option<&str>, entrypoint: &str, param_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>, @@ -713,7 +713,7 @@ async fn create_companion_action( pack: pack_id, pack_ref: pack_ref.to_string(), label: label.to_string(), - description: description.to_string(), + description: description.map(|s| s.to_string()), entrypoint: entrypoint.to_string(), runtime: None, runtime_version_constraint: None, @@ -787,7 +787,7 @@ async fn update_companion_action( if let Some(action) = existing_action { let update_input = UpdateActionInput { 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, runtime: None, runtime_version_constraint: None, @@ -838,7 +838,7 @@ async fn ensure_companion_action( pack_id: i64, pack_ref: &str, label: &str, - description: &str, + description: Option<&str>, entrypoint: &str, param_schema: Option<&serde_json::Value>, out_schema: Option<&serde_json::Value>, @@ -853,7 +853,10 @@ async fn ensure_companion_action( // Update existing companion action let update_input = UpdateActionInput { 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()), runtime: None, runtime_version_constraint: None, diff --git a/crates/api/src/validation/params.rs b/crates/api/src/validation/params.rs index 75ebf62..1728d0f 100644 --- a/crates/api/src/validation/params.rs +++ b/crates/api/src/validation/params.rs @@ -362,7 +362,7 @@ mod tests { pack: 1, pack_ref: "test".to_string(), label: "Test Action".to_string(), - description: "Test action".to_string(), + description: Some("Test action".to_string()), entrypoint: "test.sh".to_string(), runtime: Some(1), runtime_version_constraint: None, diff --git a/crates/api/tests/helpers.rs b/crates/api/tests/helpers.rs index ccb4a81..022dac1 100644 --- a/crates/api/tests/helpers.rs +++ b/crates/api/tests/helpers.rs @@ -241,6 +241,7 @@ impl TestContext { } /// Create and authenticate a test user + #[allow(dead_code)] pub async fn with_auth(mut self) -> Result { // Generate unique username to avoid conflicts in parallel tests let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "")[..8].to_string(); @@ -394,6 +395,7 @@ impl TestContext { } /// Get authenticated token + #[allow(dead_code)] pub fn token(&self) -> Option<&str> { 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_ref: format!("pack_{}", pack_id), 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(), runtime: None, runtime_version_constraint: None, diff --git a/crates/api/tests/rbac_scoped_resources_api_tests.rs b/crates/api/tests/rbac_scoped_resources_api_tests.rs new file mode 100644 index 0000000..fc6b916 --- /dev/null +++ b/crates/api/tests/rbac_scoped_resources_api_tests.rs @@ -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 { + 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); +} diff --git a/crates/api/tests/sse_execution_stream_tests.rs b/crates/api/tests/sse_execution_stream_tests.rs index 851f4f2..a053671 100644 --- a/crates/api/tests/sse_execution_stream_tests.rs +++ b/crates/api/tests/sse_execution_stream_tests.rs @@ -52,7 +52,7 @@ async fn setup_test_pack_and_action(pool: &PgPool) -> Result<(Pack, Action)> { pack: pack.id, pack_ref: pack.r#ref.clone(), 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(), runtime: None, runtime_version_constraint: None, diff --git a/crates/cli/src/commands/action.rs b/crates/cli/src/commands/action.rs index 5c2f0fb..f121816 100644 --- a/crates/cli/src/commands/action.rs +++ b/crates/cli/src/commands/action.rs @@ -90,7 +90,7 @@ struct Action { action_ref: String, pack_ref: String, label: String, - description: String, + description: Option, entrypoint: String, runtime: Option, created: String, @@ -105,7 +105,7 @@ struct ActionDetail { pack: i64, pack_ref: String, label: String, - description: String, + description: Option, entrypoint: String, runtime: Option, param_schema: Option, @@ -253,7 +253,7 @@ async fn handle_list( .runtime .map(|r| r.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()), ("Pack", action.pack_ref.clone()), ("Label", action.label.clone()), - ("Description", action.description.clone()), + ( + "Description", + action.description.unwrap_or_else(|| "None".to_string()), + ), ("Entry Point", action.entrypoint.clone()), ( "Runtime", @@ -356,7 +359,10 @@ async fn handle_update( ("Ref", action.action_ref.clone()), ("Pack", action.pack_ref.clone()), ("Label", action.label.clone()), - ("Description", action.description.clone()), + ( + "Description", + action.description.unwrap_or_else(|| "None".to_string()), + ), ("Entrypoint", action.entrypoint.clone()), ( "Runtime", diff --git a/crates/cli/src/commands/rule.rs b/crates/cli/src/commands/rule.rs index 5304b62..f29a061 100644 --- a/crates/cli/src/commands/rule.rs +++ b/crates/cli/src/commands/rule.rs @@ -112,7 +112,7 @@ struct Rule { pack: Option, pack_ref: String, label: String, - description: String, + description: Option, #[serde(default)] trigger: Option, trigger_ref: String, @@ -133,7 +133,7 @@ struct RuleDetail { pack: Option, pack_ref: String, label: String, - description: String, + description: Option, #[serde(default)] trigger: Option, trigger_ref: String, @@ -321,7 +321,10 @@ async fn handle_show( ("Ref", rule.rule_ref.clone()), ("Pack", rule.pack_ref.clone()), ("Label", rule.label.clone()), - ("Description", rule.description.clone()), + ( + "Description", + rule.description.unwrap_or_else(|| "None".to_string()), + ), ("Trigger", rule.trigger_ref.clone()), ("Action", rule.action_ref.clone()), ("Enabled", output::format_bool(rule.enabled)), @@ -440,7 +443,10 @@ async fn handle_update( ("Ref", rule.rule_ref.clone()), ("Pack", rule.pack_ref.clone()), ("Label", rule.label.clone()), - ("Description", rule.description.clone()), + ( + "Description", + rule.description.unwrap_or_else(|| "None".to_string()), + ), ("Trigger", rule.trigger_ref.clone()), ("Action", rule.action_ref.clone()), ("Enabled", output::format_bool(rule.enabled)), diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 4512bb8..44c3063 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -887,7 +887,7 @@ pub mod trigger { pub pack: Option, pub pack_ref: Option, pub label: String, - pub description: String, + pub description: Option, pub entrypoint: String, pub runtime: Id, pub runtime_ref: String, @@ -915,7 +915,7 @@ pub mod action { pub pack: Id, pub pack_ref: String, pub label: String, - pub description: String, + pub description: Option, pub entrypoint: String, pub runtime: Option, /// Optional semver version constraint for the runtime @@ -965,7 +965,7 @@ pub mod rule { pub pack: Id, pub pack_ref: String, pub label: String, - pub description: String, + pub description: Option, pub action: Option, pub action_ref: String, pub trigger: Option, @@ -1221,6 +1221,7 @@ pub mod identity { pub display_name: Option, pub password_hash: Option, pub attributes: JsonDict, + pub frozen: bool, pub created: DateTime, pub updated: DateTime, } @@ -1245,6 +1246,25 @@ pub mod identity { pub permset: Id, pub created: DateTime, } + + #[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, + pub updated: DateTime, + } + + #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] + pub struct PermissionSetRoleAssignment { + pub id: Id, + pub permset: Id, + pub role: String, + pub created: DateTime, + } } /// Key/Value storage diff --git a/crates/common/src/pack_registry/loader.rs b/crates/common/src/pack_registry/loader.rs index 31c29c1..cb39ac4 100644 --- a/crates/common/src/pack_registry/loader.rs +++ b/crates/common/src/pack_registry/loader.rs @@ -725,8 +725,7 @@ impl<'a> PackComponentLoader<'a> { let description = data .get("description") .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); let enabled = data .get("enabled") @@ -745,7 +744,10 @@ impl<'a> PackComponentLoader<'a> { if let Some(existing) = TriggerRepository::find_by_ref(self.pool, &trigger_ref).await? { let update_input = UpdateTriggerInput { label: Some(label), - description: Some(Patch::Set(description)), + description: Some(match description { + Some(description) => Patch::Set(description), + None => Patch::Clear, + }), enabled: Some(enabled), param_schema: Some(match param_schema { Some(value) => Patch::Set(value), @@ -778,7 +780,7 @@ impl<'a> PackComponentLoader<'a> { pack: Some(self.pack_id), pack_ref: Some(self.pack_ref.clone()), label, - description: Some(description), + description, enabled, param_schema, out_schema, @@ -858,8 +860,7 @@ impl<'a> PackComponentLoader<'a> { let description = data .get("description") .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); // ── Workflow file handling ────────────────────────────────── // If the action declares `workflow_file`, load the referenced @@ -876,7 +877,7 @@ impl<'a> PackComponentLoader<'a> { wf_path, &action_ref, &label, - &description, + description.as_deref().unwrap_or(""), &data, ) .await @@ -956,7 +957,10 @@ impl<'a> PackComponentLoader<'a> { if let Some(existing) = ActionRepository::find_by_ref(self.pool, &action_ref).await? { let update_input = UpdateActionInput { label: Some(label), - description: Some(description), + description: Some(match description { + Some(description) => Patch::Set(description), + None => Patch::Clear, + }), entrypoint: Some(entrypoint), runtime: runtime_id, runtime_version_constraint: Some(match runtime_version_constraint { @@ -1310,8 +1314,7 @@ impl<'a> PackComponentLoader<'a> { let description = data .get("description") .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()); let enabled = data .get("enabled") @@ -1347,7 +1350,10 @@ impl<'a> PackComponentLoader<'a> { if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? { let update_input = UpdateSensorInput { label: Some(label), - description: Some(description), + description: Some(match description { + Some(description) => Patch::Set(description), + None => Patch::Clear, + }), entrypoint: Some(entrypoint), runtime: Some(sensor_runtime_id), runtime_ref: Some(sensor_runtime_ref.clone()), diff --git a/crates/common/src/rbac.rs b/crates/common/src/rbac.rs index 9813dba..64a9b14 100644 --- a/crates/common/src/rbac.rs +++ b/crates/common/src/rbac.rs @@ -21,10 +21,6 @@ pub enum Resource { Inquiries, Keys, Artifacts, - Workflows, - Webhooks, - Analytics, - History, Identities, Permissions, } @@ -40,6 +36,7 @@ pub enum Action { Cancel, Respond, Manage, + Decrypt, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -69,6 +66,8 @@ pub struct GrantConstraints { #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_types: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_refs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub execution_scope: Option, @@ -99,6 +98,7 @@ pub struct AuthorizationContext { pub pack_ref: Option, pub owner_identity_id: Option, pub owner_type: Option, + pub owner_ref: Option, pub visibility: Option, pub encrypted: Option, pub execution_owner_identity_id: Option, @@ -115,6 +115,7 @@ impl AuthorizationContext { pack_ref: None, owner_identity_id: None, owner_type: None, + owner_ref: None, visibility: None, encrypted: 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 { let Some(target_visibility) = ctx.visibility else { return false; @@ -289,4 +299,28 @@ mod tests { .insert("team".to_string(), json!("infra")); 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)); + } } diff --git a/crates/common/src/repositories/action.rs b/crates/common/src/repositories/action.rs index ba496da..76a3d38 100644 --- a/crates/common/src/repositories/action.rs +++ b/crates/common/src/repositories/action.rs @@ -51,7 +51,7 @@ pub struct CreateActionInput { pub pack: Id, pub pack_ref: String, pub label: String, - pub description: String, + pub description: Option, pub entrypoint: String, pub runtime: Option, pub runtime_version_constraint: Option, @@ -64,7 +64,7 @@ pub struct CreateActionInput { #[derive(Debug, Clone, Default)] pub struct UpdateActionInput { pub label: Option, - pub description: Option, + pub description: Option>, pub entrypoint: Option, pub runtime: Option, pub runtime_version_constraint: Option>, @@ -210,7 +210,10 @@ impl Update for ActionRepository { query.push(", "); } query.push("description = "); - query.push_bind(description); + match description { + Patch::Set(value) => query.push_bind(value), + Patch::Clear => query.push_bind(Option::::None), + }; has_updates = true; } diff --git a/crates/common/src/repositories/artifact.rs b/crates/common/src/repositories/artifact.rs index 0896f3c..df241e4 100644 --- a/crates/common/src/repositories/artifact.rs +++ b/crates/common/src/repositories/artifact.rs @@ -577,6 +577,14 @@ pub struct CreateArtifactVersionInput { } 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) pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result> where @@ -812,14 +820,11 @@ impl ArtifactVersionRepository { E: Executor<'e, Database = Postgres> + 'e, { let query = format!( - "SELECT av.{} \ + "SELECT {} \ FROM artifact_version av \ JOIN artifact a ON av.artifact = a.id \ WHERE a.execution = $1 AND av.file_path IS NOT NULL", - artifact_version::SELECT_COLUMNS - .split(", ") - .collect::>() - .join(", av.") + Self::select_columns_with_alias("av") ); sqlx::query_as::<_, ArtifactVersion>(&query) .bind(execution_id) @@ -847,3 +852,18 @@ impl ArtifactVersionRepository { .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")); + } +} diff --git a/crates/common/src/repositories/identity.rs b/crates/common/src/repositories/identity.rs index 4c80845..20378e4 100644 --- a/crates/common/src/repositories/identity.rs +++ b/crates/common/src/repositories/identity.rs @@ -28,6 +28,7 @@ pub struct UpdateIdentityInput { pub display_name: Option, pub password_hash: Option, pub attributes: Option, + pub frozen: Option, } #[async_trait::async_trait] @@ -37,7 +38,7 @@ impl FindById for IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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) } } @@ -49,7 +50,7 @@ impl List for IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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) } } @@ -62,7 +63,7 @@ impl Create for IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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.display_name) @@ -111,6 +112,13 @@ impl Update for IdentityRepository { query.push("attributes = ").push_bind(attributes); 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 { // 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( - " RETURNING id, login, display_name, password_hash, attributes, created, updated", + " RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated", ); query @@ -156,7 +164,7 @@ impl IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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) } @@ -169,7 +177,7 @@ impl IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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 WHERE attributes->'oidc'->>'issuer' = $1 AND attributes->'oidc'->>'sub' = $2", @@ -190,7 +198,7 @@ impl IdentityRepository { E: Executor<'e, Database = Postgres> + 'e, { 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 WHERE attributes->'ldap'->>'server_url' = $1 AND attributes->'ldap'->>'dn' = $2", @@ -363,6 +371,27 @@ impl PermissionSetRepository { .map_err(Into::into) } + pub async fn find_by_roles<'e, E>(executor: E, roles: &[String]) -> Result> + 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. /// /// Used during pack reinstallation to clean up permission sets that were @@ -481,3 +510,231 @@ impl PermissionAssignmentRepository { .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> + 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 + 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 + 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> + 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> + 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> + 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 + 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 + 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> + 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) + } +} diff --git a/crates/common/src/repositories/rule.rs b/crates/common/src/repositories/rule.rs index 42cf10d..fffd169 100644 --- a/crates/common/src/repositories/rule.rs +++ b/crates/common/src/repositories/rule.rs @@ -6,7 +6,7 @@ use crate::models::{rule::*, Id}; use crate::{Error, Result}; 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`]. /// @@ -41,7 +41,7 @@ pub struct RestoreRuleInput { pub pack: Id, pub pack_ref: String, pub label: String, - pub description: String, + pub description: Option, pub action: Option, pub action_ref: String, pub trigger: Option, @@ -70,7 +70,7 @@ pub struct CreateRuleInput { pub pack: Id, pub pack_ref: String, pub label: String, - pub description: String, + pub description: Option, pub action: Id, pub action_ref: String, pub trigger: Id, @@ -86,7 +86,7 @@ pub struct CreateRuleInput { #[derive(Debug, Clone, Default)] pub struct UpdateRuleInput { pub label: Option, - pub description: Option, + pub description: Option>, pub conditions: Option, pub action_params: Option, pub trigger_params: Option, @@ -228,7 +228,10 @@ impl Update for RuleRepository { query.push(", "); } query.push("description = "); - query.push_bind(description); + match description { + Patch::Set(value) => query.push_bind(value), + Patch::Clear => query.push_bind(Option::::None), + }; has_updates = true; } diff --git a/crates/common/src/repositories/trigger.rs b/crates/common/src/repositories/trigger.rs index c60dfa8..849cdb4 100644 --- a/crates/common/src/repositories/trigger.rs +++ b/crates/common/src/repositories/trigger.rs @@ -665,7 +665,7 @@ pub struct CreateSensorInput { pub pack: Option, pub pack_ref: Option, pub label: String, - pub description: String, + pub description: Option, pub entrypoint: String, pub runtime: Id, pub runtime_ref: String, @@ -681,7 +681,7 @@ pub struct CreateSensorInput { #[derive(Debug, Clone, Default)] pub struct UpdateSensorInput { pub label: Option, - pub description: Option, + pub description: Option>, pub entrypoint: Option, pub runtime: Option, pub runtime_ref: Option, @@ -830,7 +830,10 @@ impl Update for SensorRepository { query.push(", "); } query.push("description = "); - query.push_bind(description); + match description { + Patch::Set(value) => query.push_bind(value), + Patch::Clear => query.push_bind(Option::::None), + }; has_updates = true; } diff --git a/crates/common/src/workflow/registrar.rs b/crates/common/src/workflow/registrar.rs index af7d7e4..a57d95a 100644 --- a/crates/common/src/workflow/registrar.rs +++ b/crates/common/src/workflow/registrar.rs @@ -13,6 +13,7 @@ use crate::error::{Error, Result}; use crate::repositories::action::{ActionRepository, CreateActionInput, UpdateActionInput}; use crate::repositories::workflow::{CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput}; +use crate::repositories::Patch; use crate::repositories::{ Create, Delete, FindByRef, PackRepository, Update, WorkflowDefinitionRepository, }; @@ -270,7 +271,7 @@ impl WorkflowRegistrar { pack: pack_id, pack_ref: pack_ref.to_string(), label: effective_label.to_string(), - description: workflow.description.clone().unwrap_or_default(), + description: workflow.description.clone(), entrypoint, runtime: None, runtime_version_constraint: None, @@ -317,7 +318,10 @@ impl WorkflowRegistrar { // Update the existing companion action to stay in sync let update_input = UpdateActionInput { 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)), runtime: None, runtime_version_constraint: None, diff --git a/crates/common/tests/action_repository_tests.rs b/crates/common/tests/action_repository_tests.rs index 93b6097..d844410 100644 --- a/crates/common/tests/action_repository_tests.rs +++ b/crates/common/tests/action_repository_tests.rs @@ -66,7 +66,10 @@ async fn test_create_action_with_optional_fields() { .unwrap(); 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!(action.param_schema.is_some()); assert!(action.out_schema.is_some()); @@ -204,7 +207,9 @@ async fn test_update_action() { let update = UpdateActionInput { 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() }; @@ -214,7 +219,7 @@ async fn test_update_action() { assert_eq!(updated.id, action.id); 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!(updated.updated > original_updated); } @@ -338,7 +343,7 @@ async fn test_action_foreign_key_constraint() { pack: 99999, pack_ref: "nonexistent.pack".to_string(), label: "Test Action".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), entrypoint: "main.py".to_string(), runtime: None, runtime_version_constraint: None, diff --git a/crates/common/tests/enforcement_repository_tests.rs b/crates/common/tests/enforcement_repository_tests.rs index cdd0ed0..eed7337 100644 --- a/crates/common/tests/enforcement_repository_tests.rs +++ b/crates/common/tests/enforcement_repository_tests.rs @@ -49,7 +49,7 @@ async fn test_create_enforcement_minimal() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -121,7 +121,7 @@ async fn test_create_enforcement_with_event() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -189,7 +189,7 @@ async fn test_create_enforcement_with_conditions() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -255,7 +255,7 @@ async fn test_create_enforcement_with_any_condition() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -397,7 +397,7 @@ async fn test_find_enforcement_by_id() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -471,7 +471,7 @@ async fn test_get_enforcement_by_id() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -552,7 +552,7 @@ async fn test_list_enforcements() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -624,7 +624,7 @@ async fn test_update_enforcement_status() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -690,7 +690,7 @@ async fn test_update_enforcement_status_transitions() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -769,7 +769,7 @@ async fn test_update_enforcement_payload() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -832,7 +832,7 @@ async fn test_update_enforcement_both_fields() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -896,7 +896,7 @@ async fn test_update_enforcement_no_changes() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -981,7 +981,7 @@ async fn test_delete_enforcement() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1056,7 +1056,7 @@ async fn test_find_enforcements_by_rule() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Rule 1".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1078,7 +1078,7 @@ async fn test_find_enforcements_by_rule() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Rule 2".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1149,7 +1149,7 @@ async fn test_find_enforcements_by_status() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1239,7 +1239,7 @@ async fn test_find_enforcements_by_event() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1324,7 +1324,7 @@ async fn test_delete_rule_sets_enforcement_rule_to_null() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1390,7 +1390,7 @@ async fn test_enforcement_resolved_at_lifecycle() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, diff --git a/crates/common/tests/event_repository_tests.rs b/crates/common/tests/event_repository_tests.rs index a38d50a..b416edb 100644 --- a/crates/common/tests/event_repository_tests.rs +++ b/crates/common/tests/event_repository_tests.rs @@ -449,7 +449,7 @@ async fn test_delete_event_enforcement_retains_event_id() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, diff --git a/crates/common/tests/helpers.rs b/crates/common/tests/helpers.rs index 1483510..809699e 100644 --- a/crates/common/tests/helpers.rs +++ b/crates/common/tests/helpers.rs @@ -454,7 +454,7 @@ impl ActionFixture { pack_ref: self.pack_ref, r#ref: self.r#ref, label: self.label, - description: self.description, + description: Some(self.description), entrypoint: self.entrypoint, runtime: self.runtime, runtime_version_constraint: None, @@ -1088,7 +1088,7 @@ impl SensorFixture { pack: self.pack_id, pack_ref: self.pack_ref, label: self.label, - description: self.description, + description: Some(self.description), entrypoint: self.entrypoint, runtime: self.runtime_id, runtime_ref: self.runtime_ref, diff --git a/crates/common/tests/identity_repository_tests.rs b/crates/common/tests/identity_repository_tests.rs index ec24722..c6e33d7 100644 --- a/crates/common/tests/identity_repository_tests.rs +++ b/crates/common/tests/identity_repository_tests.rs @@ -219,6 +219,7 @@ async fn test_update_identity() { display_name: Some("Updated Name".to_string()), password_hash: None, attributes: Some(json!({"key": "updated", "new_key": "new_value"})), + frozen: None, }; 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()), password_hash: None, attributes: None, + frozen: None, }; 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()), password_hash: None, attributes: None, + frozen: None, }; 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()), password_hash: None, attributes: None, + frozen: None, }; let updated = IdentityRepository::update(&pool, identity.id, update_input) diff --git a/crates/common/tests/rule_repository_tests.rs b/crates/common/tests/rule_repository_tests.rs index 0592523..28d5f9d 100644 --- a/crates/common/tests/rule_repository_tests.rs +++ b/crates/common/tests/rule_repository_tests.rs @@ -8,7 +8,7 @@ mod helpers; use attune_common::{ repositories::{ rule::{CreateRuleInput, RuleRepository, UpdateRuleInput}, - Create, Delete, FindById, FindByRef, List, Update, + Create, Delete, FindById, FindByRef, List, Patch, Update, }, Error, }; @@ -48,7 +48,7 @@ async fn test_create_rule() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test Rule".to_string(), - description: "A test rule".to_string(), + description: Some("A test rule".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -66,7 +66,7 @@ async fn test_create_rule() { assert_eq!(rule.pack, pack.id); assert_eq!(rule.pack_ref, pack.r#ref); 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_ref, action.r#ref); assert_eq!(rule.trigger, Some(trigger.id)); @@ -105,7 +105,7 @@ async fn test_create_rule_disabled() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Disabled Rule".to_string(), - description: "A disabled rule".to_string(), + description: Some("A disabled rule".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -155,7 +155,7 @@ async fn test_create_rule_with_complex_conditions() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Complex Rule".to_string(), - description: "Rule with complex conditions".to_string(), + description: Some("Rule with complex conditions".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -200,7 +200,7 @@ async fn test_create_rule_duplicate_ref() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "First Rule".to_string(), - description: "First".to_string(), + description: Some("First".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -220,7 +220,7 @@ async fn test_create_rule_duplicate_ref() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Second Rule".to_string(), - description: "Second".to_string(), + description: Some("Second".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -274,7 +274,7 @@ async fn test_create_rule_invalid_ref_format_uppercase() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Upper Rule".to_string(), - description: "Invalid uppercase ref".to_string(), + description: Some("Invalid uppercase ref".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -316,7 +316,7 @@ async fn test_create_rule_invalid_ref_format_no_dot() { pack: pack.id, pack_ref: pack.r#ref.clone(), 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_ref: action.r#ref.clone(), trigger: trigger.id, @@ -362,7 +362,7 @@ async fn test_find_rule_by_id() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Find Rule".to_string(), - description: "Rule to find".to_string(), + description: Some("Rule to find".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -422,7 +422,7 @@ async fn test_find_rule_by_ref() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Find By Ref Rule".to_string(), - description: "Find by ref".to_string(), + description: Some("Find by ref".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -484,7 +484,7 @@ async fn test_list_rules() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: format!("List Rule {}", i), - description: format!("Rule {}", i), + description: Some(format!("Rule {}", i)), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -538,7 +538,7 @@ async fn test_list_rules_ordered_by_ref() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: name.to_string(), - description: name.to_string(), + description: Some(name.to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -594,7 +594,7 @@ async fn test_update_rule_label() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Original Label".to_string(), - description: "Original".to_string(), + description: Some("Original".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -618,7 +618,7 @@ async fn test_update_rule_label() { .unwrap(); 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); } @@ -647,7 +647,7 @@ async fn test_update_rule_description() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test".to_string(), - description: "Old description".to_string(), + description: Some("Old description".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -662,7 +662,7 @@ async fn test_update_rule_description() { let created = RuleRepository::create(&pool, input).await.unwrap(); let update = UpdateRuleInput { - description: Some("New description".to_string()), + description: Some(Patch::Set("New description".to_string())), ..Default::default() }; @@ -670,7 +670,7 @@ async fn test_update_rule_description() { .await .unwrap(); - assert_eq!(updated.description, "New description"); + assert_eq!(updated.description, Some("New description".to_string())); } #[tokio::test] @@ -698,7 +698,7 @@ async fn test_update_rule_conditions() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -750,7 +750,7 @@ async fn test_update_rule_enabled() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -803,7 +803,7 @@ async fn test_update_rule_multiple_fields() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Old".to_string(), - description: "Old".to_string(), + description: Some("Old".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -819,7 +819,7 @@ async fn test_update_rule_multiple_fields() { let update = UpdateRuleInput { 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})), action_params: None, trigger_params: None, @@ -831,7 +831,7 @@ async fn test_update_rule_multiple_fields() { .unwrap(); 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!(!updated.enabled); } @@ -861,7 +861,7 @@ async fn test_update_rule_no_changes() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Test".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -914,7 +914,7 @@ async fn test_delete_rule() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "To Delete".to_string(), - description: "Will be deleted".to_string(), + description: Some("Will be deleted".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -995,7 +995,7 @@ async fn test_find_rules_by_pack() { pack: pack1.id, pack_ref: pack1.r#ref.clone(), label: format!("Rule {}", i), - description: format!("Rule {}", i), + description: Some(format!("Rule {}", i)), action: action1.id, action_ref: action1.r#ref.clone(), trigger: trigger1.id, @@ -1016,7 +1016,7 @@ async fn test_find_rules_by_pack() { pack: pack2.id, pack_ref: pack2.r#ref.clone(), label: "Pack2 Rule".to_string(), - description: "Pack2".to_string(), + description: Some("Pack2".to_string()), action: action2.id, action_ref: action2.r#ref.clone(), trigger: trigger2.id, @@ -1073,7 +1073,7 @@ async fn test_find_rules_by_action() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: format!("Action1 Rule {}", i), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action1.id, action_ref: action1.r#ref.clone(), trigger: trigger.id, @@ -1094,7 +1094,7 @@ async fn test_find_rules_by_action() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Action2 Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action2.id, action_ref: action2.r#ref.clone(), trigger: trigger.id, @@ -1155,7 +1155,7 @@ async fn test_find_rules_by_trigger() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: format!("Trigger1 Rule {}", i), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger1.id, @@ -1176,7 +1176,7 @@ async fn test_find_rules_by_trigger() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Trigger2 Rule".to_string(), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger2.id, @@ -1234,7 +1234,7 @@ async fn test_find_enabled_rules() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: format!("Enabled {}", i), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1256,7 +1256,7 @@ async fn test_find_enabled_rules() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: format!("Disabled {}", i), - description: "Test".to_string(), + description: Some("Test".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1312,7 +1312,7 @@ async fn test_cascade_delete_pack_deletes_rules() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Cascade Rule".to_string(), - description: "Will be cascade deleted".to_string(), + description: Some("Will be cascade deleted".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, @@ -1368,7 +1368,7 @@ async fn test_rule_timestamps() { pack: pack.id, pack_ref: pack.r#ref.clone(), label: "Timestamp Rule".to_string(), - description: "Test timestamps".to_string(), + description: Some("Test timestamps".to_string()), action: action.id, action_ref: action.r#ref.clone(), trigger: trigger.id, diff --git a/crates/common/tests/sensor_repository_tests.rs b/crates/common/tests/sensor_repository_tests.rs index a8adbdf..50c85aa 100644 --- a/crates/common/tests/sensor_repository_tests.rs +++ b/crates/common/tests/sensor_repository_tests.rs @@ -179,7 +179,7 @@ async fn test_create_sensor_duplicate_ref_fails() { pack: Some(pack.id), pack_ref: Some(pack.r#ref.clone()), label: "Duplicate Sensor".to_string(), - description: "Test sensor".to_string(), + description: Some("Test sensor".to_string()), entrypoint: "sensors/dup.py".to_string(), runtime: runtime.id, runtime_ref: runtime.r#ref.clone(), @@ -235,7 +235,7 @@ async fn test_create_sensor_invalid_ref_format_fails() { pack: Some(pack.id), pack_ref: Some(pack.r#ref.clone()), label: "Invalid Sensor".to_string(), - description: "Test sensor".to_string(), + description: Some("Test sensor".to_string()), entrypoint: "sensors/invalid.py".to_string(), runtime: runtime.id, 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_ref: Some("invalid".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(), runtime: runtime.id, runtime_ref: runtime.r#ref.clone(), @@ -308,7 +308,7 @@ async fn test_create_sensor_invalid_trigger_fails() { pack: None, pack_ref: None, label: "Invalid Trigger Sensor".to_string(), - description: "Test sensor".to_string(), + description: Some("Test sensor".to_string()), entrypoint: "sensors/invalid.py".to_string(), runtime: runtime.id, runtime_ref: runtime.r#ref.clone(), @@ -340,7 +340,7 @@ async fn test_create_sensor_invalid_runtime_fails() { pack: None, pack_ref: None, label: "Invalid Runtime Sensor".to_string(), - description: "Test sensor".to_string(), + description: Some("Test sensor".to_string()), entrypoint: "sensors/invalid.py".to_string(), runtime: 99999, // Non-existent runtime runtime_ref: "invalid.runtime".to_string(), @@ -728,7 +728,7 @@ async fn test_update_description() { .unwrap(); 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() }; @@ -736,7 +736,10 @@ async fn test_update_description() { .await .unwrap(); - assert_eq!(updated.description, "New description for the sensor"); + assert_eq!( + updated.description, + Some("New description for the sensor".to_string()) + ); } #[tokio::test] @@ -934,7 +937,7 @@ async fn test_update_multiple_fields() { let input = UpdateSensorInput { 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()), enabled: Some(false), param_schema: Some(Patch::Set(json!({"type": "object"}))), @@ -946,7 +949,10 @@ async fn test_update_multiple_fields() { .unwrap(); 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!(!updated.enabled); assert_eq!(updated.param_schema, Some(json!({"type": "object"}))); diff --git a/crates/executor/src/enforcement_processor.rs b/crates/executor/src/enforcement_processor.rs index 8e2cbfb..328ad4a 100644 --- a/crates/executor/src/enforcement_processor.rs +++ b/crates/executor/src/enforcement_processor.rs @@ -368,7 +368,7 @@ mod tests { pack: 1, pack_ref: "test".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: Some(1), action_ref: "test.action".to_string(), diff --git a/crates/executor/tests/fifo_ordering_integration_test.rs b/crates/executor/tests/fifo_ordering_integration_test.rs index 6d36dd0..33d3747 100644 --- a/crates/executor/tests/fifo_ordering_integration_test.rs +++ b/crates/executor/tests/fifo_ordering_integration_test.rs @@ -99,7 +99,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, pack_ref: &str, suffix: pack: pack_id, pack_ref: pack_ref.to_string(), label: format!("FIFO Test Action {}", suffix), - description: format!("Test action {}", suffix), + description: Some(format!("Test action {}", suffix)), entrypoint: "echo test".to_string(), runtime: None, runtime_version_constraint: None, diff --git a/crates/executor/tests/policy_enforcer_tests.rs b/crates/executor/tests/policy_enforcer_tests.rs index 8fcfdfd..bf96960 100644 --- a/crates/executor/tests/policy_enforcer_tests.rs +++ b/crates/executor/tests/policy_enforcer_tests.rs @@ -94,7 +94,7 @@ async fn create_test_action(pool: &PgPool, pack_id: i64, suffix: &str) -> i64 { pack: pack_id, pack_ref: format!("test_pack_{}", suffix), label: format!("Test Action {}", suffix), - description: format!("Test action {}", suffix), + description: Some(format!("Test action {}", suffix)), entrypoint: "echo test".to_string(), runtime: None, runtime_version_constraint: None, diff --git a/crates/worker/src/runtime/process.rs b/crates/worker/src/runtime/process.rs index c12e5ea..d86a767 100644 --- a/crates/worker/src/runtime/process.rs +++ b/crates/worker/src/runtime/process.rs @@ -49,6 +49,52 @@ fn bash_single_quote_escape(s: &str) -> String { 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::>(); + let cwd = cmd + .as_std() + .get_current_dir() + .map(|dir| dir.display().to_string()) + .unwrap_or_else(|| "".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) { + "".to_string() + } else { + v.to_string_lossy().into_owned() + } + }) + .unwrap_or_else(|| "".to_string()); + format!("{key}={value}") + }) + .collect::>(); + + 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. /// /// 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!( - "Running command: {:?} (action: '{}', execution_id: {}, working_dir: {:?})", - cmd, + "Running command: {} (action: '{}', execution_id: {}, working_dir: {:?})", + format_command_for_log(&cmd), context.action_ref, context.execution_id, working_dir diff --git a/migrations/20250101000003_identity_and_auth.sql b/migrations/20250101000003_identity_and_auth.sql index 1784a99..fc53c17 100644 --- a/migrations/20250101000003_identity_and_auth.sql +++ b/migrations/20250101000003_identity_and_auth.sql @@ -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 -- ============================================================================ diff --git a/migrations/20250101000004_trigger_sensor_event_rule.sql b/migrations/20250101000004_trigger_sensor_event_rule.sql index 1c30a20..bc0e4ad 100644 --- a/migrations/20250101000004_trigger_sensor_event_rule.sql +++ b/migrations/20250101000004_trigger_sensor_event_rule.sql @@ -87,7 +87,7 @@ CREATE TABLE sensor ( pack BIGINT REFERENCES pack(id) ON DELETE CASCADE, pack_ref TEXT, label TEXT NOT NULL, - description TEXT NOT NULL, + description TEXT, entrypoint TEXT NOT NULL, runtime BIGINT NOT NULL REFERENCES runtime(id) ON DELETE CASCADE, runtime_ref TEXT NOT NULL, @@ -223,7 +223,7 @@ CREATE TABLE action ( pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, pack_ref TEXT NOT NULL, label TEXT NOT NULL, - description TEXT NOT NULL, + description TEXT, entrypoint TEXT NOT NULL, runtime BIGINT REFERENCES runtime(id), param_schema JSONB, diff --git a/migrations/20250101000005_execution_and_operations.sql b/migrations/20250101000005_execution_and_operations.sql index f73d022..076ae8c 100644 --- a/migrations/20250101000005_execution_and_operations.sql +++ b/migrations/20250101000005_execution_and_operations.sql @@ -148,7 +148,7 @@ CREATE TABLE rule ( pack BIGINT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, pack_ref TEXT NOT NULL, label TEXT NOT NULL, - description TEXT NOT NULL, + description TEXT, action BIGINT REFERENCES action(id) ON DELETE SET NULL, action_ref TEXT NOT NULL, trigger BIGINT REFERENCES trigger(id) ON DELETE SET NULL, diff --git a/packs/core/permission_sets/admin.yaml b/packs/core/permission_sets/admin.yaml index 205bb45..e90923d 100644 --- a/packs/core/permission_sets/admin.yaml +++ b/packs/core/permission_sets/admin.yaml @@ -11,25 +11,17 @@ grants: - resource: triggers actions: [read, create, update, delete] - resource: executions - actions: [read, create, update, delete, cancel] + actions: [read, update, cancel] - resource: events - actions: [read, create, delete] + actions: [read] - resource: enforcements - actions: [read, create, delete] + actions: [read] - resource: inquiries actions: [read, create, update, delete, respond] - resource: keys - actions: [read, create, update, delete] + actions: [read, create, update, delete, decrypt] - resource: artifacts 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 actions: [read, create, update, delete] - resource: permissions diff --git a/packs/core/permission_sets/editor.yaml b/packs/core/permission_sets/editor.yaml index 3c10069..528f996 100644 --- a/packs/core/permission_sets/editor.yaml +++ b/packs/core/permission_sets/editor.yaml @@ -11,14 +11,8 @@ grants: - resource: triggers actions: [read] - resource: executions - actions: [read, create, cancel] + actions: [read, cancel] - resource: keys - actions: [read, update] + actions: [read, update, decrypt] - resource: artifacts actions: [read] - - resource: workflows - actions: [read, create, update] - - resource: analytics - actions: [read] - - resource: history - actions: [read] diff --git a/packs/core/permission_sets/executor.yaml b/packs/core/permission_sets/executor.yaml index ae4f71d..4f4dd2f 100644 --- a/packs/core/permission_sets/executor.yaml +++ b/packs/core/permission_sets/executor.yaml @@ -11,10 +11,8 @@ grants: - resource: triggers actions: [read] - resource: executions - actions: [read, create] + actions: [read] + - resource: keys + actions: [read] - resource: artifacts actions: [read] - - resource: analytics - actions: [read] - - resource: history - actions: [read] diff --git a/packs/core/permission_sets/viewer.yaml b/packs/core/permission_sets/viewer.yaml index fbb54ba..d1e96f4 100644 --- a/packs/core/permission_sets/viewer.yaml +++ b/packs/core/permission_sets/viewer.yaml @@ -12,9 +12,7 @@ grants: actions: [read] - resource: executions actions: [read] + - resource: keys + actions: [read] - resource: artifacts actions: [read] - - resource: analytics - actions: [read] - - resource: history - actions: [read] diff --git a/web/src/App.tsx b/web/src/App.tsx index 9e28e0e..d2f5f28 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -47,6 +47,15 @@ const TriggerCreatePage = lazy( ); const TriggerEditPage = lazy(() => import("@/pages/triggers/TriggerEditPage")); 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() { return ( @@ -134,6 +143,18 @@ function App() { /> } /> } /> + } + /> + } + /> + } + /> {/* Catch all - redirect to dashboard */} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 306c8aa..a3af722 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -9,12 +9,15 @@ export type { OpenAPIConfig } from './core/OpenAPI'; export type { ActionResponse } from './models/ActionResponse'; 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_AuthSettingsResponse } from './models/ApiResponse_AuthSettingsResponse'; export type { ApiResponse_CurrentUserResponse } from './models/ApiResponse_CurrentUserResponse'; export type { ApiResponse_EnforcementResponse } from './models/ApiResponse_EnforcementResponse'; export type { ApiResponse_EventResponse } from './models/ApiResponse_EventResponse'; 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_KeyResponse } from './models/ApiResponse_KeyResponse'; 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 { CreateActionRequest } from './models/CreateActionRequest'; export type { CreateIdentityRequest } from './models/CreateIdentityRequest'; +export type { CreateIdentityRoleAssignmentRequest } from './models/CreateIdentityRoleAssignmentRequest'; export type { CreateInquiryRequest } from './models/CreateInquiryRequest'; export type { CreateKeyRequest } from './models/CreateKeyRequest'; export type { CreatePackRequest } from './models/CreatePackRequest'; export type { CreatePermissionAssignmentRequest } from './models/CreatePermissionAssignmentRequest'; +export type { CreatePermissionSetRoleAssignmentRequest } from './models/CreatePermissionSetRoleAssignmentRequest'; export type { CreateRuleRequest } from './models/CreateRuleRequest'; export type { CreateRuntimeRequest } from './models/CreateRuntimeRequest'; export type { CreateSensorRequest } from './models/CreateSensorRequest'; @@ -53,6 +58,8 @@ export { ExecutionStatus } from './models/ExecutionStatus'; export type { ExecutionSummary } from './models/ExecutionSummary'; export type { HealthResponse } from './models/HealthResponse'; 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 { InquiryRespondRequest } from './models/InquiryRespondRequest'; export type { InquiryResponse } from './models/InquiryResponse'; @@ -61,10 +68,12 @@ export type { InquirySummary } from './models/InquirySummary'; export type { InstallPackRequest } from './models/InstallPackRequest'; export type { KeyResponse } from './models/KeyResponse'; export type { KeySummary } from './models/KeySummary'; +export type { LdapLoginRequest } from './models/LdapLoginRequest'; export type { LoginRequest } from './models/LoginRequest'; export { NullableJsonPatch } from './models/NullableJsonPatch'; export { NullableStringPatch } from './models/NullableStringPatch'; export { OwnerType } from './models/OwnerType'; +export { PackDescriptionPatch } from './models/PackDescriptionPatch'; export type { PackInstallResponse } from './models/PackInstallResponse'; export type { PackResponse } from './models/PackResponse'; 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 { PaginationMeta } from './models/PaginationMeta'; export type { PermissionAssignmentResponse } from './models/PermissionAssignmentResponse'; +export type { PermissionSetRoleAssignmentResponse } from './models/PermissionSetRoleAssignmentResponse'; export type { PermissionSetSummary } from './models/PermissionSetSummary'; export type { QueueStatsResponse } from './models/QueueStatsResponse'; export type { RefreshTokenRequest } from './models/RefreshTokenRequest'; @@ -98,6 +108,7 @@ export type { RuleResponse } from './models/RuleResponse'; export type { RuleSummary } from './models/RuleSummary'; export type { RuntimeResponse } from './models/RuntimeResponse'; export type { RuntimeSummary } from './models/RuntimeSummary'; +export { RuntimeVersionConstraintPatch } from './models/RuntimeVersionConstraintPatch'; export type { SensorResponse } from './models/SensorResponse'; export type { SensorSummary } from './models/SensorSummary'; export type { SuccessResponse } from './models/SuccessResponse'; @@ -106,6 +117,7 @@ export { TestStatus } from './models/TestStatus'; export type { TestSuiteResult } from './models/TestSuiteResult'; export type { TokenResponse } from './models/TokenResponse'; export type { TriggerResponse } from './models/TriggerResponse'; +export { TriggerStringPatch } from './models/TriggerStringPatch'; export type { TriggerSummary } from './models/TriggerSummary'; export type { UpdateActionRequest } from './models/UpdateActionRequest'; export type { UpdateIdentityRequest } from './models/UpdateIdentityRequest'; @@ -126,6 +138,7 @@ export type { WorkflowSummary } from './models/WorkflowSummary'; export type { WorkflowSyncResult } from './models/WorkflowSyncResult'; export { ActionsService } from './services/ActionsService'; +export { AgentService } from './services/AgentService'; export { AuthService } from './services/AuthService'; export { EnforcementsService } from './services/EnforcementsService'; export { EventsService } from './services/EventsService'; diff --git a/web/src/api/models/ActionResponse.ts b/web/src/api/models/ActionResponse.ts index 00eec88..bd64411 100644 --- a/web/src/api/models/ActionResponse.ts +++ b/web/src/api/models/ActionResponse.ts @@ -6,65 +6,64 @@ * Response DTO for action information */ export type ActionResponse = { - /** - * Creation timestamp - */ - created: string; - /** - * Action description - */ - description: string; - /** - * Entry point - */ - entrypoint: string; - /** - * Action ID - */ - id: number; - /** - * Whether this is an ad-hoc action (not from pack installation) - */ - is_adhoc: boolean; - /** - * Human-readable label - */ - label: string; - /** - * Output schema - */ - out_schema: any | null; - /** - * Pack ID - */ - pack: number; - /** - * Pack reference - */ - pack_ref: string; - /** - * Parameter schema (StackStorm-style with inline required/secret) - */ - param_schema: any | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime?: number | null; - /** - * Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") - */ - runtime_version_constraint?: string | null; - /** - * Last update timestamp - */ - updated: string; - /** - * Workflow definition ID (non-null if this action is a workflow) - */ - workflow_def?: number | null; + /** + * Creation timestamp + */ + created: string; + /** + * Action description + */ + description: string | null; + /** + * Entry point + */ + entrypoint: string; + /** + * Action ID + */ + id: number; + /** + * Whether this is an ad-hoc action (not from pack installation) + */ + is_adhoc: boolean; + /** + * Human-readable label + */ + label: string; + /** + * Output schema + */ + out_schema: any | null; + /** + * Pack ID + */ + pack: number; + /** + * Pack reference + */ + pack_ref: string; + /** + * Parameter schema (StackStorm-style with inline required/secret) + */ + param_schema: any | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime?: number | null; + /** + * Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") + */ + runtime_version_constraint?: string | null; + /** + * Last update timestamp + */ + updated: string; + /** + * Workflow definition ID (non-null if this action is a workflow) + */ + workflow_def?: number | null; }; - diff --git a/web/src/api/models/ActionSummary.ts b/web/src/api/models/ActionSummary.ts index 4ed5ef1..e10e29d 100644 --- a/web/src/api/models/ActionSummary.ts +++ b/web/src/api/models/ActionSummary.ts @@ -6,49 +6,48 @@ * Simplified action response (for list endpoints) */ export type ActionSummary = { - /** - * Creation timestamp - */ - created: string; - /** - * Action description - */ - description: string; - /** - * Entry point - */ - entrypoint: string; - /** - * Action ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime?: number | null; - /** - * Semver version constraint for the runtime - */ - runtime_version_constraint?: string | null; - /** - * Last update timestamp - */ - updated: string; - /** - * Workflow definition ID (non-null if this action is a workflow) - */ - workflow_def?: number | null; + /** + * Creation timestamp + */ + created: string; + /** + * Action description + */ + description: string | null; + /** + * Entry point + */ + entrypoint: string; + /** + * Action ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime?: number | null; + /** + * Semver version constraint for the runtime + */ + runtime_version_constraint?: string | null; + /** + * Last update timestamp + */ + updated: string; + /** + * Workflow definition ID (non-null if this action is a workflow) + */ + workflow_def?: number | null; }; - diff --git a/web/src/api/models/AgentArchInfo.ts b/web/src/api/models/AgentArchInfo.ts new file mode 100644 index 0000000..3bb3dfc --- /dev/null +++ b/web/src/api/models/AgentArchInfo.ts @@ -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; +}; + diff --git a/web/src/api/models/AgentBinaryInfo.ts b/web/src/api/models/AgentBinaryInfo.ts new file mode 100644 index 0000000..d0c47b3 --- /dev/null +++ b/web/src/api/models/AgentBinaryInfo.ts @@ -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; + /** + * Agent version (from build) + */ + version: string; +}; + diff --git a/web/src/api/models/ApiResponse_ActionResponse.ts b/web/src/api/models/ApiResponse_ActionResponse.ts index 6ba9fca..231df36 100644 --- a/web/src/api/models/ApiResponse_ActionResponse.ts +++ b/web/src/api/models/ApiResponse_ActionResponse.ts @@ -6,74 +6,73 @@ * Standard API response wrapper */ export type ApiResponse_ActionResponse = { + /** + * Response DTO for action information + */ + data: { /** - * Response DTO for action information + * Creation timestamp */ - data: { - /** - * Creation timestamp - */ - created: string; - /** - * Action description - */ - description: string; - /** - * Entry point - */ - entrypoint: string; - /** - * Action ID - */ - id: number; - /** - * Whether this is an ad-hoc action (not from pack installation) - */ - is_adhoc: boolean; - /** - * Human-readable label - */ - label: string; - /** - * Output schema - */ - out_schema: any | null; - /** - * Pack ID - */ - pack: number; - /** - * Pack reference - */ - pack_ref: string; - /** - * Parameter schema (StackStorm-style with inline required/secret) - */ - param_schema: any | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime?: number | null; - /** - * Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") - */ - runtime_version_constraint?: string | null; - /** - * Last update timestamp - */ - updated: string; - /** - * Workflow definition ID (non-null if this action is a workflow) - */ - workflow_def?: number | null; - }; + created: string; /** - * Optional message + * Action description */ - message?: string | null; + description: string | null; + /** + * Entry point + */ + entrypoint: string; + /** + * Action ID + */ + id: number; + /** + * Whether this is an ad-hoc action (not from pack installation) + */ + is_adhoc: boolean; + /** + * Human-readable label + */ + label: string; + /** + * Output schema + */ + out_schema: any | null; + /** + * Pack ID + */ + pack: number; + /** + * Pack reference + */ + pack_ref: string; + /** + * Parameter schema (StackStorm-style with inline required/secret) + */ + param_schema: any | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime?: number | null; + /** + * Semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") + */ + runtime_version_constraint?: string | null; + /** + * Last update timestamp + */ + updated: string; + /** + * Workflow definition ID (non-null if this action is a workflow) + */ + workflow_def?: number | null; + }; + /** + * Optional message + */ + message?: string | null; }; - diff --git a/web/src/api/models/ApiResponse_AuthSettingsResponse.ts b/web/src/api/models/ApiResponse_AuthSettingsResponse.ts new file mode 100644 index 0000000..9042d24 --- /dev/null +++ b/web/src/api/models/ApiResponse_AuthSettingsResponse.ts @@ -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=`. + */ + 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=`. + */ + 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; +}; + diff --git a/web/src/api/models/ApiResponse_IdentitySummary.ts b/web/src/api/models/ApiResponse_IdentityResponse.ts similarity index 53% rename from web/src/api/models/ApiResponse_IdentitySummary.ts rename to web/src/api/models/ApiResponse_IdentityResponse.ts index 3ac5c88..b045006 100644 --- a/web/src/api/models/ApiResponse_IdentitySummary.ts +++ b/web/src/api/models/ApiResponse_IdentityResponse.ts @@ -2,16 +2,21 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { IdentityRoleAssignmentResponse } from './IdentityRoleAssignmentResponse'; +import type { PermissionAssignmentResponse } from './PermissionAssignmentResponse'; import type { Value } from './Value'; /** * Standard API response wrapper */ -export type ApiResponse_IdentitySummary = { +export type ApiResponse_IdentityResponse = { data: { attributes: Value; + direct_permissions: Array; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }; /** * Optional message diff --git a/web/src/api/models/ApiResponse_RuleResponse.ts b/web/src/api/models/ApiResponse_RuleResponse.ts index 2f54ddb..9e217d6 100644 --- a/web/src/api/models/ApiResponse_RuleResponse.ts +++ b/web/src/api/models/ApiResponse_RuleResponse.ts @@ -6,82 +6,81 @@ * Standard API response wrapper */ export type ApiResponse_RuleResponse = { + /** + * Response DTO for rule information + */ + data: { /** - * Response DTO for rule information + * Action ID (null if the referenced action has been deleted) */ - data: { - /** - * Action ID (null if the referenced action has been deleted) - */ - action?: number | null; - /** - * Parameters to pass to the action when rule is triggered - */ - action_params: Record; - /** - * Action reference - */ - action_ref: string; - /** - * Conditions for rule evaluation - */ - conditions: Record; - /** - * Creation timestamp - */ - created: string; - /** - * Rule description - */ - description: string; - /** - * Whether the rule is enabled - */ - enabled: boolean; - /** - * Rule ID - */ - id: number; - /** - * Whether this is an ad-hoc rule (not from pack installation) - */ - is_adhoc: boolean; - /** - * Human-readable label - */ - label: string; - /** - * Pack ID - */ - pack: number; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Trigger ID (null if the referenced trigger has been deleted) - */ - trigger?: number | null; - /** - * Parameters for trigger configuration and event filtering - */ - trigger_params: Record; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; - }; + action?: number | null; /** - * Optional message + * Parameters to pass to the action when rule is triggered */ - message?: string | null; + action_params: Record; + /** + * Action reference + */ + action_ref: string; + /** + * Conditions for rule evaluation + */ + conditions: Record; + /** + * Creation timestamp + */ + created: string; + /** + * Rule description + */ + description: string | null; + /** + * Whether the rule is enabled + */ + enabled: boolean; + /** + * Rule ID + */ + id: number; + /** + * Whether this is an ad-hoc rule (not from pack installation) + */ + is_adhoc: boolean; + /** + * Human-readable label + */ + label: string; + /** + * Pack ID + */ + pack: number; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Trigger ID (null if the referenced trigger has been deleted) + */ + trigger?: number | null; + /** + * Parameters for trigger configuration and event filtering + */ + trigger_params: Record; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; + }; + /** + * Optional message + */ + message?: string | null; }; - diff --git a/web/src/api/models/ApiResponse_SensorResponse.ts b/web/src/api/models/ApiResponse_SensorResponse.ts index e3020b0..09d720f 100644 --- a/web/src/api/models/ApiResponse_SensorResponse.ts +++ b/web/src/api/models/ApiResponse_SensorResponse.ts @@ -6,74 +6,73 @@ * Standard API response wrapper */ export type ApiResponse_SensorResponse = { + /** + * Response DTO for sensor information + */ + data: { /** - * Response DTO for sensor information + * Creation timestamp */ - data: { - /** - * Creation timestamp - */ - created: string; - /** - * Sensor description - */ - description: string; - /** - * Whether the sensor is enabled - */ - enabled: boolean; - /** - * Entry point - */ - entrypoint: string; - /** - * Sensor ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack ID (optional) - */ - pack?: number | null; - /** - * Pack reference (optional) - */ - pack_ref?: string | null; - /** - * Parameter schema (StackStorm-style with inline required/secret) - */ - param_schema: any | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime: number; - /** - * Runtime reference - */ - runtime_ref: string; - /** - * Trigger ID - */ - trigger: number; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; - }; + created: string; /** - * Optional message + * Sensor description */ - message?: string | null; + description: string | null; + /** + * Whether the sensor is enabled + */ + enabled: boolean; + /** + * Entry point + */ + entrypoint: string; + /** + * Sensor ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack ID (optional) + */ + pack?: number | null; + /** + * Pack reference (optional) + */ + pack_ref?: string | null; + /** + * Parameter schema (StackStorm-style with inline required/secret) + */ + param_schema: any | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime: number; + /** + * Runtime reference + */ + runtime_ref: string; + /** + * Trigger ID + */ + trigger: number; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; + }; + /** + * Optional message + */ + message?: string | null; }; - diff --git a/web/src/api/models/CreateActionRequest.ts b/web/src/api/models/CreateActionRequest.ts index 2a6619d..e702c92 100644 --- a/web/src/api/models/CreateActionRequest.ts +++ b/web/src/api/models/CreateActionRequest.ts @@ -6,41 +6,40 @@ * Request DTO for creating a new action */ export type CreateActionRequest = { - /** - * Action description - */ - description: string; - /** - * Entry point for action execution (e.g., path to script, function name) - */ - entrypoint: string; - /** - * Human-readable label - */ - label: string; - /** - * Output schema (flat format) defining expected outputs with inline required/secret - */ - out_schema?: any | null; - /** - * Pack reference this action belongs to - */ - pack_ref: string; - /** - * Parameter schema (StackStorm-style) defining expected inputs with inline required/secret - */ - param_schema?: any | null; - /** - * Unique reference identifier (e.g., "core.http", "aws.ec2.start_instance") - */ - ref: string; - /** - * Optional runtime ID for this action - */ - runtime?: number | null; - /** - * Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") - */ - runtime_version_constraint?: string | null; + /** + * Action description + */ + description?: string | null; + /** + * Entry point for action execution (e.g., path to script, function name) + */ + entrypoint: string; + /** + * Human-readable label + */ + label: string; + /** + * Output schema (flat format) defining expected outputs with inline required/secret + */ + out_schema?: any | null; + /** + * Pack reference this action belongs to + */ + pack_ref: string; + /** + * Parameter schema (StackStorm-style) defining expected inputs with inline required/secret + */ + param_schema?: any | null; + /** + * Unique reference identifier (e.g., "core.http", "aws.ec2.start_instance") + */ + ref: string; + /** + * Optional runtime ID for this action + */ + runtime?: number | null; + /** + * Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") + */ + runtime_version_constraint?: string | null; }; - diff --git a/web/src/api/models/CreateIdentityRoleAssignmentRequest.ts b/web/src/api/models/CreateIdentityRoleAssignmentRequest.ts new file mode 100644 index 0000000..03a54bf --- /dev/null +++ b/web/src/api/models/CreateIdentityRoleAssignmentRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreateIdentityRoleAssignmentRequest = { + role: string; +}; + diff --git a/web/src/api/models/CreatePermissionSetRoleAssignmentRequest.ts b/web/src/api/models/CreatePermissionSetRoleAssignmentRequest.ts new file mode 100644 index 0000000..a1f8ecb --- /dev/null +++ b/web/src/api/models/CreatePermissionSetRoleAssignmentRequest.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreatePermissionSetRoleAssignmentRequest = { + role: string; +}; + diff --git a/web/src/api/models/CreateRuleRequest.ts b/web/src/api/models/CreateRuleRequest.ts index d648e49..e26ca30 100644 --- a/web/src/api/models/CreateRuleRequest.ts +++ b/web/src/api/models/CreateRuleRequest.ts @@ -6,45 +6,44 @@ * Request DTO for creating a new rule */ export type CreateRuleRequest = { - /** - * Parameters to pass to the action when rule is triggered - */ - action_params?: Record; - /** - * Action reference to execute when rule matches - */ - action_ref: string; - /** - * Conditions for rule evaluation (JSON Logic or custom format) - */ - conditions?: Record; - /** - * Rule description - */ - description: string; - /** - * Whether the rule is enabled - */ - enabled?: boolean; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference this rule belongs to - */ - pack_ref: string; - /** - * Unique reference identifier (e.g., "mypack.notify_on_error") - */ - ref: string; - /** - * Parameters for trigger configuration and event filtering - */ - trigger_params?: Record; - /** - * Trigger reference that activates this rule - */ - trigger_ref: string; + /** + * Parameters to pass to the action when rule is triggered + */ + action_params?: Record; + /** + * Action reference to execute when rule matches + */ + action_ref: string; + /** + * Conditions for rule evaluation (JSON Logic or custom format) + */ + conditions?: Record; + /** + * Rule description + */ + description?: string | null; + /** + * Whether the rule is enabled + */ + enabled?: boolean; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference this rule belongs to + */ + pack_ref: string; + /** + * Unique reference identifier (e.g., "mypack.notify_on_error") + */ + ref: string; + /** + * Parameters for trigger configuration and event filtering + */ + trigger_params?: Record; + /** + * Trigger reference that activates this rule + */ + trigger_ref: string; }; - diff --git a/web/src/api/models/CreateSensorRequest.ts b/web/src/api/models/CreateSensorRequest.ts index 1dcfde8..59a00c9 100644 --- a/web/src/api/models/CreateSensorRequest.ts +++ b/web/src/api/models/CreateSensorRequest.ts @@ -6,45 +6,44 @@ * Request DTO for creating a new sensor */ export type CreateSensorRequest = { - /** - * Configuration values for this sensor instance (conforms to param_schema) - */ - config?: any | null; - /** - * Sensor description - */ - description: string; - /** - * Whether the sensor is enabled - */ - enabled?: boolean; - /** - * Entry point for sensor execution (e.g., path to script, function name) - */ - entrypoint: string; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference this sensor belongs to - */ - pack_ref: string; - /** - * Parameter schema (flat format) for sensor configuration - */ - param_schema?: any | null; - /** - * Unique reference identifier (e.g., "mypack.cpu_monitor") - */ - ref: string; - /** - * Runtime reference for this sensor - */ - runtime_ref: string; - /** - * Trigger reference this sensor monitors for - */ - trigger_ref: string; + /** + * Configuration values for this sensor instance (conforms to param_schema) + */ + config?: any | null; + /** + * Sensor description + */ + description?: string | null; + /** + * Whether the sensor is enabled + */ + enabled?: boolean; + /** + * Entry point for sensor execution (e.g., path to script, function name) + */ + entrypoint: string; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference this sensor belongs to + */ + pack_ref: string; + /** + * Parameter schema (flat format) for sensor configuration + */ + param_schema?: any | null; + /** + * Unique reference identifier (e.g., "mypack.cpu_monitor") + */ + ref: string; + /** + * Runtime reference for this sensor + */ + runtime_ref: string; + /** + * Trigger reference this sensor monitors for + */ + trigger_ref: string; }; - diff --git a/web/src/api/models/IdentityResponse.ts b/web/src/api/models/IdentityResponse.ts new file mode 100644 index 0000000..64332fa --- /dev/null +++ b/web/src/api/models/IdentityResponse.ts @@ -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; + display_name?: string | null; + frozen: boolean; + id: number; + login: string; + roles: Array; +}; + diff --git a/web/src/api/models/IdentityRoleAssignmentResponse.ts b/web/src/api/models/IdentityRoleAssignmentResponse.ts new file mode 100644 index 0000000..b12c5ee --- /dev/null +++ b/web/src/api/models/IdentityRoleAssignmentResponse.ts @@ -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; +}; + diff --git a/web/src/api/models/IdentitySummary.ts b/web/src/api/models/IdentitySummary.ts index 4feb44e..872c729 100644 --- a/web/src/api/models/IdentitySummary.ts +++ b/web/src/api/models/IdentitySummary.ts @@ -6,7 +6,9 @@ import type { Value } from './Value'; export type IdentitySummary = { attributes: Value; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }; diff --git a/web/src/api/models/LdapLoginRequest.ts b/web/src/api/models/LdapLoginRequest.ts new file mode 100644 index 0000000..fdbf61a --- /dev/null +++ b/web/src/api/models/LdapLoginRequest.ts @@ -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; +}; + diff --git a/web/src/api/models/PackDescriptionPatch.ts b/web/src/api/models/PackDescriptionPatch.ts new file mode 100644 index 0000000..3663261 --- /dev/null +++ b/web/src/api/models/PackDescriptionPatch.ts @@ -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", + } +} diff --git a/web/src/api/models/PaginatedResponse_ActionSummary.ts b/web/src/api/models/PaginatedResponse_ActionSummary.ts index 4bcdb32..2bd9ad9 100644 --- a/web/src/api/models/PaginatedResponse_ActionSummary.ts +++ b/web/src/api/models/PaginatedResponse_ActionSummary.ts @@ -2,63 +2,62 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { PaginationMeta } from './PaginationMeta'; +import type { PaginationMeta } from "./PaginationMeta"; /** * Paginated response wrapper */ export type PaginatedResponse_ActionSummary = { + /** + * The data items + */ + data: Array<{ /** - * The data items + * Creation timestamp */ - data: Array<{ - /** - * Creation timestamp - */ - created: string; - /** - * Action description - */ - description: string; - /** - * Entry point - */ - entrypoint: string; - /** - * Action ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime?: number | null; - /** - * Semver version constraint for the runtime - */ - runtime_version_constraint?: string | null; - /** - * Last update timestamp - */ - updated: string; - /** - * Workflow definition ID (non-null if this action is a workflow) - */ - workflow_def?: number | null; - }>; + created: string; /** - * Pagination metadata + * Action description */ - pagination: PaginationMeta; + description: string | null; + /** + * Entry point + */ + entrypoint: string; + /** + * Action ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime?: number | null; + /** + * Semver version constraint for the runtime + */ + runtime_version_constraint?: string | null; + /** + * Last update timestamp + */ + updated: string; + /** + * Workflow definition ID (non-null if this action is a workflow) + */ + workflow_def?: number | null; + }>; + /** + * Pagination metadata + */ + pagination: PaginationMeta; }; - diff --git a/web/src/api/models/PaginatedResponse_IdentitySummary.ts b/web/src/api/models/PaginatedResponse_IdentitySummary.ts index 0968f8c..3048581 100644 --- a/web/src/api/models/PaginatedResponse_IdentitySummary.ts +++ b/web/src/api/models/PaginatedResponse_IdentitySummary.ts @@ -14,8 +14,10 @@ export type PaginatedResponse_IdentitySummary = { data: Array<{ attributes: Value; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }>; /** * Pagination metadata diff --git a/web/src/api/models/PaginatedResponse_RuleSummary.ts b/web/src/api/models/PaginatedResponse_RuleSummary.ts index 047121a..a82c8f6 100644 --- a/web/src/api/models/PaginatedResponse_RuleSummary.ts +++ b/web/src/api/models/PaginatedResponse_RuleSummary.ts @@ -2,67 +2,66 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { PaginationMeta } from './PaginationMeta'; +import type { PaginationMeta } from "./PaginationMeta"; /** * Paginated response wrapper */ export type PaginatedResponse_RuleSummary = { + /** + * The data items + */ + data: Array<{ /** - * The data items + * Parameters to pass to the action when rule is triggered */ - data: Array<{ - /** - * Parameters to pass to the action when rule is triggered - */ - action_params: Record; - /** - * Action reference - */ - action_ref: string; - /** - * Creation timestamp - */ - created: string; - /** - * Rule description - */ - description: string; - /** - * Whether the rule is enabled - */ - enabled: boolean; - /** - * Rule ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Parameters for trigger configuration and event filtering - */ - trigger_params: Record; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; - }>; + action_params: Record; /** - * Pagination metadata + * Action reference */ - pagination: PaginationMeta; + action_ref: string; + /** + * Creation timestamp + */ + created: string; + /** + * Rule description + */ + description: string | null; + /** + * Whether the rule is enabled + */ + enabled: boolean; + /** + * Rule ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Parameters for trigger configuration and event filtering + */ + trigger_params: Record; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; + }>; + /** + * Pagination metadata + */ + pagination: PaginationMeta; }; - diff --git a/web/src/api/models/PaginatedResponse_SensorSummary.ts b/web/src/api/models/PaginatedResponse_SensorSummary.ts index ff69804..8698bbf 100644 --- a/web/src/api/models/PaginatedResponse_SensorSummary.ts +++ b/web/src/api/models/PaginatedResponse_SensorSummary.ts @@ -2,55 +2,54 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { PaginationMeta } from './PaginationMeta'; +import type { PaginationMeta } from "./PaginationMeta"; /** * Paginated response wrapper */ export type PaginatedResponse_SensorSummary = { + /** + * The data items + */ + data: Array<{ /** - * The data items + * Creation timestamp */ - data: Array<{ - /** - * Creation timestamp - */ - created: string; - /** - * Sensor description - */ - description: string; - /** - * Whether the sensor is enabled - */ - enabled: boolean; - /** - * Sensor ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference (optional) - */ - pack_ref?: string | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; - }>; + created: string; /** - * Pagination metadata + * Sensor description */ - pagination: PaginationMeta; + description: string | null; + /** + * Whether the sensor is enabled + */ + enabled: boolean; + /** + * Sensor ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference (optional) + */ + pack_ref?: string | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; + }>; + /** + * Pagination metadata + */ + pagination: PaginationMeta; }; - diff --git a/web/src/api/models/PermissionSetRoleAssignmentResponse.ts b/web/src/api/models/PermissionSetRoleAssignmentResponse.ts new file mode 100644 index 0000000..5db3465 --- /dev/null +++ b/web/src/api/models/PermissionSetRoleAssignmentResponse.ts @@ -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; +}; + diff --git a/web/src/api/models/PermissionSetSummary.ts b/web/src/api/models/PermissionSetSummary.ts index 78ef4eb..a67c299 100644 --- a/web/src/api/models/PermissionSetSummary.ts +++ b/web/src/api/models/PermissionSetSummary.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { PermissionSetRoleAssignmentResponse } from './PermissionSetRoleAssignmentResponse'; import type { Value } from './Value'; export type PermissionSetSummary = { description?: string | null; @@ -10,5 +11,6 @@ export type PermissionSetSummary = { label?: string | null; pack_ref?: string | null; ref: string; + roles: Array; }; diff --git a/web/src/api/models/RuleResponse.ts b/web/src/api/models/RuleResponse.ts index e080ed1..8fcc364 100644 --- a/web/src/api/models/RuleResponse.ts +++ b/web/src/api/models/RuleResponse.ts @@ -6,73 +6,72 @@ * Response DTO for rule information */ export type RuleResponse = { - /** - * Action ID (null if the referenced action has been deleted) - */ - action?: number | null; - /** - * Parameters to pass to the action when rule is triggered - */ - action_params: Record; - /** - * Action reference - */ - action_ref: string; - /** - * Conditions for rule evaluation - */ - conditions: Record; - /** - * Creation timestamp - */ - created: string; - /** - * Rule description - */ - description: string; - /** - * Whether the rule is enabled - */ - enabled: boolean; - /** - * Rule ID - */ - id: number; - /** - * Whether this is an ad-hoc rule (not from pack installation) - */ - is_adhoc: boolean; - /** - * Human-readable label - */ - label: string; - /** - * Pack ID - */ - pack: number; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Trigger ID (null if the referenced trigger has been deleted) - */ - trigger?: number | null; - /** - * Parameters for trigger configuration and event filtering - */ - trigger_params: Record; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; + /** + * Action ID (null if the referenced action has been deleted) + */ + action?: number | null; + /** + * Parameters to pass to the action when rule is triggered + */ + action_params: Record; + /** + * Action reference + */ + action_ref: string; + /** + * Conditions for rule evaluation + */ + conditions: Record; + /** + * Creation timestamp + */ + created: string; + /** + * Rule description + */ + description: string | null; + /** + * Whether the rule is enabled + */ + enabled: boolean; + /** + * Rule ID + */ + id: number; + /** + * Whether this is an ad-hoc rule (not from pack installation) + */ + is_adhoc: boolean; + /** + * Human-readable label + */ + label: string; + /** + * Pack ID + */ + pack: number; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Trigger ID (null if the referenced trigger has been deleted) + */ + trigger?: number | null; + /** + * Parameters for trigger configuration and event filtering + */ + trigger_params: Record; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; }; - diff --git a/web/src/api/models/RuleSummary.ts b/web/src/api/models/RuleSummary.ts index 6a75991..f06c4fb 100644 --- a/web/src/api/models/RuleSummary.ts +++ b/web/src/api/models/RuleSummary.ts @@ -6,53 +6,52 @@ * Simplified rule response (for list endpoints) */ export type RuleSummary = { - /** - * Parameters to pass to the action when rule is triggered - */ - action_params: Record; - /** - * Action reference - */ - action_ref: string; - /** - * Creation timestamp - */ - created: string; - /** - * Rule description - */ - description: string; - /** - * Whether the rule is enabled - */ - enabled: boolean; - /** - * Rule ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference - */ - pack_ref: string; - /** - * Unique reference identifier - */ - ref: string; - /** - * Parameters for trigger configuration and event filtering - */ - trigger_params: Record; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; + /** + * Parameters to pass to the action when rule is triggered + */ + action_params: Record; + /** + * Action reference + */ + action_ref: string; + /** + * Creation timestamp + */ + created: string; + /** + * Rule description + */ + description: string | null; + /** + * Whether the rule is enabled + */ + enabled: boolean; + /** + * Rule ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference + */ + pack_ref: string; + /** + * Unique reference identifier + */ + ref: string; + /** + * Parameters for trigger configuration and event filtering + */ + trigger_params: Record; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; }; - diff --git a/web/src/api/models/RuntimeVersionConstraintPatch.ts b/web/src/api/models/RuntimeVersionConstraintPatch.ts new file mode 100644 index 0000000..f884330 --- /dev/null +++ b/web/src/api/models/RuntimeVersionConstraintPatch.ts @@ -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', + } +} + diff --git a/web/src/api/models/SensorResponse.ts b/web/src/api/models/SensorResponse.ts index a45e8cd..af71fd6 100644 --- a/web/src/api/models/SensorResponse.ts +++ b/web/src/api/models/SensorResponse.ts @@ -6,65 +6,64 @@ * Response DTO for sensor information */ export type SensorResponse = { - /** - * Creation timestamp - */ - created: string; - /** - * Sensor description - */ - description: string; - /** - * Whether the sensor is enabled - */ - enabled: boolean; - /** - * Entry point - */ - entrypoint: string; - /** - * Sensor ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack ID (optional) - */ - pack?: number | null; - /** - * Pack reference (optional) - */ - pack_ref?: string | null; - /** - * Parameter schema (StackStorm-style with inline required/secret) - */ - param_schema: any | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Runtime ID - */ - runtime: number; - /** - * Runtime reference - */ - runtime_ref: string; - /** - * Trigger ID - */ - trigger: number; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; + /** + * Creation timestamp + */ + created: string; + /** + * Sensor description + */ + description: string | null; + /** + * Whether the sensor is enabled + */ + enabled: boolean; + /** + * Entry point + */ + entrypoint: string; + /** + * Sensor ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack ID (optional) + */ + pack?: number | null; + /** + * Pack reference (optional) + */ + pack_ref?: string | null; + /** + * Parameter schema (StackStorm-style with inline required/secret) + */ + param_schema: any | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Runtime ID + */ + runtime: number; + /** + * Runtime reference + */ + runtime_ref: string; + /** + * Trigger ID + */ + trigger: number; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; }; - diff --git a/web/src/api/models/SensorSummary.ts b/web/src/api/models/SensorSummary.ts index 05cdd53..46a8a76 100644 --- a/web/src/api/models/SensorSummary.ts +++ b/web/src/api/models/SensorSummary.ts @@ -6,41 +6,40 @@ * Simplified sensor response (for list endpoints) */ export type SensorSummary = { - /** - * Creation timestamp - */ - created: string; - /** - * Sensor description - */ - description: string; - /** - * Whether the sensor is enabled - */ - enabled: boolean; - /** - * Sensor ID - */ - id: number; - /** - * Human-readable label - */ - label: string; - /** - * Pack reference (optional) - */ - pack_ref?: string | null; - /** - * Unique reference identifier - */ - ref: string; - /** - * Trigger reference - */ - trigger_ref: string; - /** - * Last update timestamp - */ - updated: string; + /** + * Creation timestamp + */ + created: string; + /** + * Sensor description + */ + description: string | null; + /** + * Whether the sensor is enabled + */ + enabled: boolean; + /** + * Sensor ID + */ + id: number; + /** + * Human-readable label + */ + label: string; + /** + * Pack reference (optional) + */ + pack_ref?: string | null; + /** + * Unique reference identifier + */ + ref: string; + /** + * Trigger reference + */ + trigger_ref: string; + /** + * Last update timestamp + */ + updated: string; }; - diff --git a/web/src/api/models/TriggerStringPatch.ts b/web/src/api/models/TriggerStringPatch.ts new file mode 100644 index 0000000..40c5098 --- /dev/null +++ b/web/src/api/models/TriggerStringPatch.ts @@ -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", + } +} diff --git a/web/src/api/models/UpdateActionRequest.ts b/web/src/api/models/UpdateActionRequest.ts index 434e1b1..0f6f215 100644 --- a/web/src/api/models/UpdateActionRequest.ts +++ b/web/src/api/models/UpdateActionRequest.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { RuntimeVersionConstraintPatch } from './RuntimeVersionConstraintPatch'; /** * Request DTO for updating an action */ @@ -30,9 +31,6 @@ export type UpdateActionRequest = { * Runtime ID */ runtime?: number | null; - /** - * Optional semver version constraint for the runtime (e.g., ">=3.12", ">=3.12,<4.0", "~18.0") - */ - runtime_version_constraint?: string | null; + runtime_version_constraint?: (null | RuntimeVersionConstraintPatch); }; diff --git a/web/src/api/models/UpdateIdentityRequest.ts b/web/src/api/models/UpdateIdentityRequest.ts index 1e946b9..86429d0 100644 --- a/web/src/api/models/UpdateIdentityRequest.ts +++ b/web/src/api/models/UpdateIdentityRequest.ts @@ -6,6 +6,7 @@ import type { Value } from './Value'; export type UpdateIdentityRequest = { attributes?: (null | Value); display_name?: string | null; + frozen?: boolean | null; password?: string | null; }; diff --git a/web/src/api/models/UpdatePackRequest.ts b/web/src/api/models/UpdatePackRequest.ts index c94737e..3510a2d 100644 --- a/web/src/api/models/UpdatePackRequest.ts +++ b/web/src/api/models/UpdatePackRequest.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { PackDescriptionPatch } from './PackDescriptionPatch'; /** * Request DTO for updating a pack */ @@ -18,10 +19,7 @@ export type UpdatePackRequest = { * Pack dependencies (refs of required packs) */ dependencies?: any[] | null; - /** - * Pack description - */ - description?: string | null; + description?: (null | PackDescriptionPatch); /** * Whether this is a standard pack */ diff --git a/web/src/api/models/UpdateTriggerRequest.ts b/web/src/api/models/UpdateTriggerRequest.ts index 5f18c45..d660d07 100644 --- a/web/src/api/models/UpdateTriggerRequest.ts +++ b/web/src/api/models/UpdateTriggerRequest.ts @@ -2,14 +2,12 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { TriggerStringPatch } from './TriggerStringPatch'; /** * Request DTO for updating a trigger */ export type UpdateTriggerRequest = { - /** - * Trigger description - */ - description?: string | null; + description?: (null | TriggerStringPatch); /** * Whether the trigger is enabled */ diff --git a/web/src/api/services/AgentService.ts b/web/src/api/services/AgentService.ts new file mode 100644 index 0000000..54c6a1b --- /dev/null +++ b/web/src/api/services/AgentService.ts @@ -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 { + 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 { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/agent/info', + errors: { + 503: `Agent binary distribution not configured`, + }, + }); + } +} diff --git a/web/src/api/services/AuthService.ts b/web/src/api/services/AuthService.ts index f3b029a..5059fe6 100644 --- a/web/src/api/services/AuthService.ts +++ b/web/src/api/services/AuthService.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { ChangePasswordRequest } from '../models/ChangePasswordRequest'; +import type { LdapLoginRequest } from '../models/LdapLoginRequest'; import type { LoginRequest } from '../models/LoginRequest'; import type { RefreshTokenRequest } from '../models/RefreshTokenRequest'; 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 * 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=`. + */ + 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=`. + */ + 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', + }); + } } diff --git a/web/src/api/services/PermissionsService.ts b/web/src/api/services/PermissionsService.ts index 09f4469..3d4e8aa 100644 --- a/web/src/api/services/PermissionsService.ts +++ b/web/src/api/services/PermissionsService.ts @@ -3,7 +3,10 @@ /* tslint:disable */ /* eslint-disable */ import type { CreateIdentityRequest } from '../models/CreateIdentityRequest'; +import type { CreateIdentityRoleAssignmentRequest } from '../models/CreateIdentityRoleAssignmentRequest'; 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 { PermissionAssignmentResponse } from '../models/PermissionAssignmentResponse'; import type { PermissionSetSummary } from '../models/PermissionSetSummary'; @@ -50,9 +53,12 @@ export class PermissionsService { }): CancelablePromise<{ data: { attributes: Value; + direct_permissions: Array; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }; /** * 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 * @throws ApiError @@ -83,9 +130,12 @@ export class PermissionsService { }): CancelablePromise<{ data: { attributes: Value; + direct_permissions: Array; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }; /** * Optional message @@ -119,9 +169,12 @@ export class PermissionsService { }): CancelablePromise<{ data: { attributes: Value; + direct_permissions: Array; display_name?: string | null; + frozen: boolean; id: number; login: string; + roles: Array; }; /** * 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 * @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 * @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`, + }, + }); + } } diff --git a/web/src/components/forms/PackForm.tsx b/web/src/components/forms/PackForm.tsx index 18819a3..d60bd05 100644 --- a/web/src/components/forms/PackForm.tsx +++ b/web/src/components/forms/PackForm.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useCreatePack, useUpdatePack } from "@/hooks/usePacks"; -import type { PackResponse } from "@/api"; +import { PackDescriptionPatch, type PackResponse } from "@/api"; import { labelToRef } from "@/lib/format-utils"; import SchemaBuilder from "@/components/common/SchemaBuilder"; import ParamSchemaForm, { @@ -173,7 +173,9 @@ export default function PackForm({ pack, onSuccess, onCancel }: PackFormProps) { if (isEditing) { const updateData = { label: label.trim(), - description: description.trim() || undefined, + description: description.trim() + ? { op: PackDescriptionPatch.op.SET, value: description.trim() } + : { op: PackDescriptionPatch.op.CLEAR }, version: version.trim(), conf_schema: parsedConfSchema, config: configValues, diff --git a/web/src/components/forms/RuleForm.tsx b/web/src/components/forms/RuleForm.tsx index 2817cd5..6cb4bf7 100644 --- a/web/src/components/forms/RuleForm.tsx +++ b/web/src/components/forms/RuleForm.tsx @@ -9,6 +9,7 @@ import ParamSchemaForm, { type ParamSchema, } from "@/components/common/ParamSchemaForm"; import SearchableSelect from "@/components/common/SearchableSelect"; +import RuleMatchConditionsEditor from "@/components/forms/RuleMatchConditionsEditor"; import type { RuleResponse, ActionSummary, @@ -40,9 +41,21 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { const [description, setDescription] = useState(rule?.description || ""); const [triggerId, setTriggerId] = useState(rule?.trigger || 0); const [actionId, setActionId] = useState(rule?.action || 0); - const [conditions, setConditions] = useState( - rule?.conditions ? JSON.stringify(rule.conditions, null, 2) : "", - ); + const [conditions, setConditions] = useState(() => { + 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< Record >(rule?.trigger_params || {}); @@ -57,6 +70,7 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { const [actionParamErrors, setActionParamErrors] = useState< Record >({}); + const [conditionsError, setConditionsError] = useState(); // Data fetching const { data: packsData } = usePacks({ pageSize: 1000 }); @@ -143,10 +157,6 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { newErrors.label = "Label is required"; } - if (!description.trim()) { - newErrors.description = "Description is required"; - } - if (!packId) { newErrors.pack = "Pack is required"; } @@ -159,13 +169,8 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { newErrors.action = "Action is required"; } - // Validate conditions JSON if provided - if (conditions.trim()) { - try { - JSON.parse(conditions); - } catch { - newErrors.conditions = "Invalid JSON format"; - } + if (conditionsError) { + newErrors.conditions = conditionsError; } // Validate trigger parameters (allow templates in rule context) @@ -210,15 +215,18 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { pack_ref: selectedPackData?.ref || "", ref: fullRef, label: label.trim(), - description: description.trim(), trigger_ref: selectedTrigger?.ref || "", action_ref: selectedAction?.ref || "", enabled, }; + if (description.trim()) { + formData.description = description.trim(); + } + // Only add optional fields if they have values - if (conditions.trim()) { - formData.conditions = JSON.parse(conditions); + if (conditions !== undefined) { + formData.conditions = conditions; } // Add trigger parameters if any @@ -274,280 +282,252 @@ export default function RuleForm({ rule, onSuccess, onCancel }: RuleFormProps) { )} {/* Basic Information */} -
+

Basic Information

- {/* Pack Selection */} -
- - setPackId(Number(v))} - options={packs.map((pack) => ({ - value: pack.id, - label: `${pack.label} (${pack.version})`, - }))} - placeholder="Select a pack..." - disabled={isEditing} - error={!!errors.pack} - /> - {errors.pack && ( -

{errors.pack}

- )} -
+
+ {/* Pack Selection */} +
+ + setPackId(Number(v))} + options={packs.map((pack) => ({ + value: pack.id, + label: `${pack.label} (${pack.version})`, + }))} + placeholder="Select a pack..." + disabled={isEditing} + error={!!errors.pack} + /> + {errors.pack && ( +

{errors.pack}

+ )} +
- {/* Label - MOVED FIRST */} -
- - setLabel(e.target.value)} - onBlur={() => { - // Auto-populate localRef from label if localRef is empty and not editing - if (!isEditing && !localRef.trim() && label.trim()) { - setLocalRef(labelToRef(label)); - } - }} - placeholder="e.g., Notify on Error" - className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ - errors.label ? "border-red-500" : "border-gray-300" - }`} - /> - {errors.label && ( -

{errors.label}

- )} -

- Human-readable name for display -

-
- - {/* Reference - MOVED AFTER LABEL with Pack Prefix */} -
- -
- - {selectedPack?.ref || "pack"}. - + {/* Label */} +
+ setLocalRef(e.target.value)} - placeholder="e.g., notify_on_error" - disabled={isEditing} - className={errors.ref ? "error" : ""} + id="label" + value={label} + onChange={(e) => setLabel(e.target.value)} + onBlur={() => { + // Auto-populate localRef from label if localRef is empty and not editing + if (!isEditing && !localRef.trim() && label.trim()) { + setLocalRef(labelToRef(label)); + } + }} + placeholder="e.g., Notify on Error" + className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.label ? "border-red-500" : "border-gray-300" + }`} /> + {errors.label && ( +

{errors.label}

+ )}
- {errors.ref && ( -

{errors.ref}

- )} -

- Local identifier within the pack. Auto-populated from label. -

-
- {/* Description */} -
- -