first pass at access control setup

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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Vec<Grant>, 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",
}
}

View File

@@ -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<String>,
/// 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<String>,
/// Action description
#[validate(length(min = 1))]
#[schema(example = "Posts a message to a Slack channel with enhanced features")]
pub description: Option<String>,
@@ -121,7 +119,7 @@ pub struct ActionResponse {
/// Action description
#[schema(example = "Posts a message to a Slack channel")]
pub description: String,
pub description: Option<String>,
/// 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<String>,
/// 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,

View File

@@ -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};

View File

@@ -14,10 +14,32 @@ pub struct IdentitySummary {
pub id: i64,
pub login: String,
pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue,
pub roles: Vec<String>,
}
pub type IdentityResponse = IdentitySummary;
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityRoleAssignmentResponse {
pub id: i64,
pub identity_id: i64,
pub role: String,
pub source: String,
pub managed: bool,
pub created: chrono::DateTime<chrono::Utc>,
pub updated: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IdentityResponse {
pub id: i64,
pub login: String,
pub display_name: Option<String>,
pub frozen: bool,
pub attributes: JsonValue,
pub roles: Vec<IdentityRoleAssignmentResponse>,
pub direct_permissions: Vec<PermissionAssignmentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetSummary {
@@ -27,6 +49,7 @@ pub struct PermissionSetSummary {
pub label: Option<String>,
pub description: Option<String>,
pub grants: JsonValue,
pub roles: Vec<PermissionSetRoleAssignmentResponse>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -38,6 +61,15 @@ pub struct PermissionAssignmentResponse {
pub created: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PermissionSetRoleAssignmentResponse {
pub id: i64,
pub permission_set_id: i64,
pub permission_set_ref: Option<String>,
pub role: String,
pub created: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreatePermissionAssignmentRequest {
pub identity_id: Option<i64>,
@@ -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<String>,
pub password: Option<String>,
pub attributes: Option<JsonValue>,
pub frozen: Option<bool>,
}

View File

@@ -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<String>,
/// Action reference to execute when rule matches
#[validate(length(min = 1, max = 255))]
@@ -69,7 +68,6 @@ pub struct UpdateRuleRequest {
pub label: Option<String>,
/// Rule description
#[validate(length(min = 1))]
#[schema(example = "Enhanced error notification with filtering")]
pub description: Option<String>,
@@ -115,7 +113,7 @@ pub struct RuleResponse {
/// Rule description
#[schema(example = "Send Slack notification when an error occurs")]
pub description: String,
pub description: Option<String>,
/// Action ID (null if the referenced action has been deleted)
#[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<String>,
/// 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!({

View File

@@ -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<String>,
/// 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<String>,
/// Sensor description
#[validate(length(min = 1))]
#[schema(example = "Enhanced CPU monitoring with alerts")]
pub description: Option<String>,
@@ -297,7 +295,7 @@ pub struct SensorResponse {
/// Sensor description
#[schema(example = "Monitors CPU usage and generates events")]
pub description: String,
pub description: Option<String>,
/// 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<String>,
/// 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(),

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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<Arc<AppState>>,
Query(query): Query<ArtifactQueryParams>,
) -> ApiResult<impl IntoResponse> {
@@ -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<ArtifactSummary> = result.rows.into_iter().map(ArtifactSummary::from).collect();
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
rows.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = rows.into_iter().map(ArtifactSummary::from).collect();
let pagination = PaginationParams {
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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Path(artifact_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Json(request): Json<CreateArtifactRequest>,
) -> ApiResult<impl IntoResponse> {
@@ -200,6 +218,16 @@ pub async fn create_artifact(
}
});
authorize_artifact_create(
&state,
&user,
&request.r#ref,
request.scope,
&request.owner,
visibility,
)
.await?;
let input = CreateArtifactInput {
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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<UpdateArtifactRequest>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Path(execution_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
let artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
let mut artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
if let Some((identity_id, grants)) = ensure_can_read_any_artifact(&state, &user).await? {
artifacts.retain(|artifact| {
let ctx = artifact_authorization_context(identity_id, artifact);
AuthorizationService::is_allowed(&grants, Resource::Artifacts, Action::Read, &ctx)
});
}
let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect();
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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<AppendProgressRequest>,
@@ -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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<SetDataRequest>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
// 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<ArtifactVersionSummary> = 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<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<CreateVersionJsonRequest>,
) -> ApiResult<impl IntoResponse> {
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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<CreateFileVersionRequest>,
@@ -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<Arc<AppState>>,
Path(id): Path<i64>,
mut multipart: Multipart,
) -> ApiResult<impl IntoResponse> {
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<Vec<u8>> = None;
let mut content_type: Option<String> = None;
let mut meta: Option<serde_json::Value> = 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<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Path((id, version)): Path<(i64, i32)>,
) -> ApiResult<impl IntoResponse> {
@@ -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<Arc<AppState>>,
Path(artifact_ref): Path<String>,
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<Arc<AppState>>,
Path(artifact_ref): Path<String>,
Json(request): Json<AllocateFileVersionByRefRequest>,
@@ -1305,6 +1387,8 @@ pub async fn allocate_file_version_by_ref(
// Upsert: find existing artifact or create a new one
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<AppState>,
user: &AuthenticatedUser,
action: Action,
artifact: &attune_common::models::artifact::Artifact,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action,
context: artifact_authorization_context(identity_id, artifact),
},
)
.await
}
async fn authorize_artifact_create(
state: &Arc<AppState>,
user: &AuthenticatedUser,
artifact_ref: &str,
scope: OwnerType,
owner: &str,
visibility: ArtifactVisibility,
) -> Result<(), ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(());
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_ref = Some(artifact_ref.to_string());
ctx.owner_type = Some(scope);
ctx.owner_ref = Some(owner.to_string());
ctx.visibility = Some(visibility);
authz
.authorize(
user,
AuthorizationCheck {
resource: Resource::Artifacts,
action: Action::Create,
context: ctx,
},
)
.await
}
async fn ensure_can_read_any_artifact(
state: &Arc<AppState>,
user: &AuthenticatedUser,
) -> Result<Option<(i64, Vec<attune_common::rbac::Grant>)>, ApiError> {
if user.claims.token_type != TokenType::Access {
return Ok(None);
}
let identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
let authz = AuthorizationService::new(state.db.clone());
let grants = authz.effective_grants(user).await?;
let can_read_any_artifact = grants
.iter()
.any(|g| g.resource == Resource::Artifacts && g.actions.contains(&Action::Read));
if !can_read_any_artifact {
return Err(ApiError::Forbidden(
"Insufficient permissions: artifacts:read".to_string(),
));
}
Ok(Some((identity_id, grants)))
}
fn artifact_authorization_context(
identity_id: i64,
artifact: &attune_common::models::artifact::Artifact,
) -> AuthorizationContext {
let mut ctx = AuthorizationContext::new(identity_id);
ctx.target_id = Some(artifact.id);
ctx.target_ref = Some(artifact.r#ref.clone());
ctx.owner_type = Some(artifact.scope);
ctx.owner_ref = Some(artifact.owner.clone());
ctx.visibility = Some(artifact.visibility);
ctx
}
/// Returns true for artifact types that should use file-backed storage on disk.
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. \

View File

@@ -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?;

View File

@@ -82,6 +82,17 @@ pub async fn create_event(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateEventRequest>,
) -> ApiResult<impl IntoResponse> {
// Only sensor and execution tokens may create events directly.
// User sessions must go through the webhook receiver instead.
use crate::auth::jwt::TokenType;
if user.0.claims.token_type == TokenType::Access {
return Err(ApiError::Forbidden(
"Events may only be created by sensor services. To fire an event as a user, \
enable webhooks on the trigger and POST to its webhook URL."
.to_string(),
));
}
// Validate request
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

View File

@@ -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

View File

@@ -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<String> {
key_owner_ref(
request.owner_type,
request.owner.as_deref(),
request.owner_pack_ref.as_deref(),
request.owner_action_ref.as_deref(),
request.owner_sensor_ref.as_deref(),
)
}
fn key_owner_ref(
owner_type: OwnerType,
owner: Option<&str>,
owner_pack_ref: Option<&str>,
owner_action_ref: Option<&str>,
owner_sensor_ref: Option<&str>,
) -> Option<String> {
match owner_type {
OwnerType::Pack => owner_pack_ref.map(str::to_string),
OwnerType::Action => owner_action_ref.map(str::to_string),
OwnerType::Sensor => owner_sensor_ref.map(str::to_string),
_ => owner.map(str::to_string),
}
}

View File

@@ -9,12 +9,14 @@ use std::sync::Arc;
use validator::Validate;
use 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::<std::collections::HashMap<_, _>>();
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<PermissionSetSummary> = 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<IdentityRoleAssignmentResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
Json(request): Json<CreateIdentityRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
let assignment = IdentityRoleAssignmentRepository::create(
&state.db,
CreateIdentityRoleAssignmentInput {
identity: identity_id,
role: request.role,
source: "manual".to_string(),
managed: false,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(IdentityRoleAssignmentResponse::from(
assignment,
))),
))
}
#[utoipa::path(
delete,
path = "/api/v1/identities/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity role assignment ID")
),
responses(
(status = 200, description = "Identity role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_identity_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
let assignment = IdentityRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Identity role assignment '{}' not found",
assignment_id
))
})?;
if assignment.managed {
return Err(ApiError::BadRequest(
"Managed role assignments must be updated through the identity provider sync"
.to_string(),
));
}
IdentityRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Identity role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/permissions/sets/{id}/roles",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set ID")
),
request_body = CreatePermissionSetRoleAssignmentRequest,
responses(
(status = 201, description = "Permission set role assignment created", body = inline(ApiResponse<PermissionSetRoleAssignmentResponse>)),
(status = 404, description = "Permission set not found")
),
security(("bearer_auth" = []))
)]
pub async fn create_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(permission_set_id): Path<i64>,
Json(request): Json<CreatePermissionSetRoleAssignmentRequest>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
request.validate()?;
let permission_set = PermissionSetRepository::find_by_id(&state.db, permission_set_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!("Permission set '{}' not found", permission_set_id))
})?;
let assignment = PermissionSetRoleAssignmentRepository::create(
&state.db,
CreatePermissionSetRoleAssignmentInput {
permset: permission_set_id,
role: request.role,
},
)
.await?;
Ok((
StatusCode::CREATED,
Json(ApiResponse::new(PermissionSetRoleAssignmentResponse {
id: assignment.id,
permission_set_id: assignment.permset,
permission_set_ref: Some(permission_set.r#ref),
role: assignment.role,
created: assignment.created,
})),
))
}
#[utoipa::path(
delete,
path = "/api/v1/permissions/sets/roles/{id}",
tag = "permissions",
params(
("id" = i64, Path, description = "Permission set role assignment ID")
),
responses(
(status = 200, description = "Permission set role assignment deleted", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Permission set role assignment not found")
),
security(("bearer_auth" = []))
)]
pub async fn delete_permission_set_role_assignment(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(assignment_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(&state, &user, Resource::Permissions, Action::Manage).await?;
PermissionSetRoleAssignmentRepository::find_by_id(&state.db, assignment_id)
.await?
.ok_or_else(|| {
ApiError::NotFound(format!(
"Permission set role assignment '{}' not found",
assignment_id
))
})?;
PermissionSetRoleAssignmentRepository::delete(&state.db, assignment_id).await?;
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(
"Permission set role assignment deleted successfully",
))),
))
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/freeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity frozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn freeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, true).await
}
#[utoipa::path(
post,
path = "/api/v1/identities/{id}/unfreeze",
tag = "permissions",
params(
("id" = i64, Path, description = "Identity ID")
),
responses(
(status = 200, description = "Identity unfrozen", body = inline(ApiResponse<SuccessResponse>)),
(status = 404, description = "Identity not found")
),
security(("bearer_auth" = []))
)]
pub async fn unfreeze_identity(
State(state): State<Arc<AppState>>,
RequireAuth(user): RequireAuth,
Path(identity_id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
set_identity_frozen(&state, &user, identity_id, false).await
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/identities", get(list_identities).post(create_identity))
@@ -421,11 +711,29 @@ pub fn routes() -> Router<Arc<AppState>> {
.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<Identity> for IdentitySummary {
id: value.id,
login: value.login,
display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes,
roles: Vec::new(),
}
}
}
impl From<PermissionSet> for PermissionSetSummary {
fn from(value: PermissionSet) -> Self {
impl From<IdentityRoleAssignment> 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<Identity> for IdentityResponse {
fn from(value: Identity) -> Self {
Self {
id: value.id,
login: value.login,
display_name: value.display_name,
frozen: value.frozen,
attributes: value.attributes,
roles: Vec::new(),
direct_permissions: Vec::new(),
}
}
}
async fn set_identity_frozen(
state: &Arc<AppState>,
user: &crate::auth::middleware::AuthenticatedUser,
identity_id: i64,
frozen: bool,
) -> ApiResult<impl IntoResponse> {
authorize_permissions(state, user, Resource::Identities, Action::Update).await?;
let caller_identity_id = user
.identity_id()
.map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?;
if caller_identity_id == identity_id && frozen {
return Err(ApiError::BadRequest(
"Refusing to freeze the currently authenticated identity".to_string(),
));
}
IdentityRepository::find_by_id(&state.db, identity_id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("Identity '{}' not found", identity_id)))?;
IdentityRepository::update(
&state.db,
identity_id,
UpdateIdentityInput {
display_name: None,
password_hash: None,
attributes: None,
frozen: Some(frozen),
},
)
.await?;
let message = if frozen {
"Identity frozen successfully"
} else {
"Identity unfrozen successfully"
};
Ok((
StatusCode::OK,
Json(ApiResponse::new(SuccessResponse::new(message))),
))
}

View File

@@ -20,7 +20,7 @@ use attune_common::repositories::{
pack::PackRepository,
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,

View File

@@ -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,

View File

@@ -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<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
RequireAuth(_user): RequireAuth,
RequireAuth(user): RequireAuth,
Path(trigger_ref): Path<String>,
) -> ApiResult<impl IntoResponse> {
// 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(

View File

@@ -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,

View File

@@ -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,