diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 1ce72dda9..a9a76a1a0 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -253,19 +253,33 @@ impl From for KeystoneApiError { } impl From for KeystoneApiError { - fn from(source: ApplicationCredentialProviderError) -> Self { - match source { + fn from(value: ApplicationCredentialProviderError) -> Self { + match value { ApplicationCredentialProviderError::ApplicationCredentialNotFound(x) => { Self::NotFound { resource: "application_credential".into(), identifier: x, } } + ApplicationCredentialProviderError::AccessRuleNotFound(x) => Self::NotFound { + resource: "access_rule".into(), + identifier: x, + }, + ApplicationCredentialProviderError::RoleNotFound(x) => Self::NotFound { + resource: "role".into(), + identifier: x, + }, ref err @ ApplicationCredentialProviderError::Conflict(..) => { Self::Conflict(err.to_string()) } - err @ ApplicationCredentialProviderError::ApplicationCredentialExpired => { - Self::unauthorized(err, None::) + ref err @ ApplicationCredentialProviderError::Validation { .. } => { + Self::BadRequest(err.to_string()) + } + ApplicationCredentialProviderError::ApplicationCredentialExpired => { + Self::BadRequest("application credential has expired".into()) + } + ApplicationCredentialProviderError::AccessRuleInUse(_) => { + Self::Conflict("application credential access rule is in use".into()) } err @ ApplicationCredentialProviderError::AccessRulesUnenforced => { Self::BadRequest(err.to_string()) diff --git a/crates/api-types/src/v3.rs b/crates/api-types/src/v3.rs index 606868fe7..8a5d50f44 100644 --- a/crates/api-types/src/v3.rs +++ b/crates/api-types/src/v3.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 //! # V3 API types +pub mod application_credential; pub mod auth; pub mod credential; pub mod domain; @@ -27,6 +28,8 @@ pub mod service; pub mod trust; pub mod user; +#[cfg(feature = "conv")] +mod application_credential_conv; #[cfg(feature = "conv")] mod auth_conv; #[cfg(feature = "conv")] diff --git a/crates/api-types/src/v3/application_credential.rs b/crates/api-types/src/v3/application_credential.rs new file mode 100644 index 000000000..38d75ddb6 --- /dev/null +++ b/crates/api-types/src/v3/application_credential.rs @@ -0,0 +1,16 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +pub mod access_rule; +pub mod application_credential; diff --git a/crates/api-types/src/v3/application_credential/access_rule.rs b/crates/api-types/src/v3/application_credential/access_rule.rs new file mode 100644 index 000000000..f7837ee09 --- /dev/null +++ b/crates/api-types/src/v3/application_credential/access_rule.rs @@ -0,0 +1,114 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +/// Short access rule representation. +/// +/// Access rules are fine-grained permissions attached to application +/// credentials. Each rule constrains the credential to a specific service +/// type, HTTP method, and API path. Once created, an access rule can be +/// viewed and deleted independently of the application credential. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct AccessRule { + /// Unique identifier of the access rule. This ID can be used to reuse an + /// existing access rule when creating a new application credential. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: String, + + /// HTTP method that this access rule permits (e.g., `GET`, `POST`, `PUT`, + /// `DELETE`, `PATCH`). + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 16)))] + pub method: Option, + + /// API path pattern that this access rule permits. Supports wildcard + /// syntax: `*` matches a single path segment, `**` matches any number + /// of segments recursively, and `{variable}` matches a named path + /// parameter. For example, `/v2.1/servers/*/ips` or `/v2.1/**`. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 128)))] + pub path: Option, + + /// OpenStack service type that this access rule applies to + /// (e.g., `compute`, `monitoring`, `identity`). Matched against the + /// service catalog. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub service: Option, +} + +/// Access rule for creation (id is optional). +/// +/// When creating an application credential, access rules can either be +/// defined inline (with `method`, `path`, and `service`) or reference an +/// existing rule by its `id`. All fields are optional so that both creation +/// patterns are supported. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct AccessRuleCreate { + /// Optional identifier of an existing access rule to reuse. When + /// provided, the other fields (`method`, `path`, `service`) are ignored + /// and the referenced rule is attached to the new application credential. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: Option, + + /// HTTP method that this access rule permits (e.g., `GET`, `POST`, `PUT`, + /// `DELETE`, `PATCH`). Required when creating a new rule inline (i.e., + /// without specifying `id`). + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 16)))] + pub method: Option, + + /// API path pattern that this access rule permits. Supports wildcard + /// syntax: `*` matches a single path segment, `**` matches any number + /// of segments recursively, and `{variable}` matches a named path + /// parameter. Required when creating a new rule inline. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 128)))] + pub path: Option, + + /// OpenStack service type that this access rule applies to + /// (e.g., `compute`, `monitoring`, `identity`). Matched against the + /// service catalog. Required when creating a new rule inline. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub service: Option, +} diff --git a/crates/api-types/src/v3/application_credential/application_credential.rs b/crates/api-types/src/v3/application_credential/application_credential.rs new file mode 100644 index 000000000..2f87389ae --- /dev/null +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -0,0 +1,234 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Application credential API types + +use chrono::{DateTime, Utc}; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validate")] +use validator::Validate; + +use crate::common; +use crate::v3::application_credential::access_rule::{AccessRule, AccessRuleCreate}; +use crate::v3::role::RoleRef; +/// Full application credential representation. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredential { + /// Optional list of access rules that restrict which API requests this + /// credential is permitted to make. Each rule specifies a service, HTTP + /// method, and URL path. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(nested))] + pub access_rules: Option>, + + /// Optional human-readable description of the application credential's + /// purpose. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub description: Option, + + /// Optional expiration date and time for the application credential. After + /// this timestamp the credential is no longer valid. When `None`, the + /// credential does not expire. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + /// Unique identifier of the application credential. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub id: String, + + /// User-provided name of the application credential. Must be unique within + /// the owning user's set of application credentials. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub name: String, + + /// Identifier of the project that the application credential is scoped to. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub project_id: String, + + /// List of roles delegated to this application credential. These must be a + /// subset of the roles the owning user holds on the target project. + #[cfg_attr(feature = "validate", validate(nested))] + pub roles: Vec, + + /// Whether this application credential has unrestricted access. When + /// `false` (the default), the credential cannot be used to create + /// additional application credentials or trusts. + pub unrestricted: bool, +} + +/// Data for creating an application credential. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr( + feature = "builder", + derive(derive_builder::Builder), + builder( + build_fn(error = "crate::error::BuilderError"), + setter(strip_option, into) + ) +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreate { + /// Optional list of access rules to restrict which API requests the new + /// credential is allowed to make. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(nested))] + pub access_rules: Option>, + + /// Optional human-readable description of the application credential. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional expiration date and time. When `None`, the credential does not + /// expire. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + /// Name of the application credential. Must be unique among the owning + /// user's application credentials. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] + pub name: String, + + /// Roles to delegate to the new credential. Must be a subset of the + /// user's roles on the project. Defaults to all of the user's roles on the + /// project when empty. + #[cfg_attr(feature = "builder", builder(default))] + pub roles: Vec, + + /// Optional secret to use for the new credential. If not provided, a + /// random secret will be generated. + #[cfg_attr(feature = "builder", builder(default))] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "common::serialize_optional_secret" + )] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub secret: Option, + + /// Whether to allow unrestricted access. When `true`, the credential can + /// create additional application credentials or trusts, which is + /// potentially dangerous. Defaults to `false`. + #[cfg_attr(feature = "builder", builder(default))] + #[serde(skip_serializing_if = "Option::is_none")] + pub unrestricted: Option, +} + +/// Wrapper for a single application credential response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialResponse { + /// The application credential object. + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credential: ApplicationCredential, +} + +/// Wrapper for a create request body. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreateRequest { + /// The application credential creation payload. + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credential: ApplicationCredentialCreate, +} + +/// Application credential as returned by create — includes secret (shown once only). +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreated { + /// Optional list of access rules associated with the credential. + #[serde(skip_serializing_if = "Option::is_none")] + pub access_rules: Option>, + + /// Optional human-readable description of the application credential. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional expiration date and time. `None` means the credential does not + /// expire. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + + /// Unique identifier of the newly created application credential. + pub id: String, + + /// Name of the application credential. + pub name: String, + + /// Identifier of the project the credential is scoped to. + pub project_id: String, + + /// List of roles delegated to this application credential. + pub roles: Vec, + + /// The secret used for authentication. The secret is hashed before storage, + /// so this is the only time it is returned in plaintext. If lost, a new + /// application credential must be created. + #[serde(serialize_with = "common::serialize_secret_string")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub secret: SecretString, + + /// Whether this credential has unrestricted access. + pub unrestricted: bool, +} + +/// Wrapper for create response body. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialCreateResponse { + /// The newly created application credential, including the one-time + /// secret. + pub application_credential: ApplicationCredentialCreated, +} + +/// List of application credentials. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialList { + /// Collection of application credentials belonging to the user. + #[cfg_attr(feature = "validate", validate(nested))] + pub application_credentials: Vec, +} + +/// List parameters for filtering application credentials. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ApplicationCredentialListParameters { + /// Optional name filter. When set, only application credentials whose + /// name matches this value are returned. + #[cfg_attr(feature = "validate", validate(length(max = 255)))] + pub name: Option, +} diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs new file mode 100644 index 000000000..c57dde33b --- /dev/null +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -0,0 +1,159 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use secrecy::ExposeSecret; + +use openstack_keystone_core_types::application_credential as provider_types; + +use crate::v3::application_credential::access_rule as api_types_access_rule; +use crate::v3::application_credential::application_credential as api_types_application_credential; + +impl From for api_types_access_rule::AccessRule { + fn from(value: provider_types::AccessRule) -> Self { + Self { + id: value.id, + method: value.method, + path: value.path, + service: value.service, + } + } +} + +impl From + for api_types_application_credential::ApplicationCredential +{ + fn from(value: provider_types::ApplicationCredential) -> Self { + Self { + access_rules: value + .access_rules + .map(|rules| rules.into_iter().map(Into::into).collect()), + description: value.description, + expires_at: value.expires_at, + id: value.id, + name: value.name, + project_id: value.project_id, + roles: value.roles.into_iter().map(Into::into).collect(), + unrestricted: value.unrestricted, + } + } +} + +impl From + for api_types_application_credential::ApplicationCredentialCreated +{ + fn from(value: provider_types::ApplicationCredentialCreateResponse) -> Self { + Self { + access_rules: value + .access_rules + .map(|rules| rules.into_iter().map(Into::into).collect()), + description: value.description, + expires_at: value.expires_at, + id: value.id, + name: value.name, + project_id: value.project_id, + roles: value.roles.into_iter().map(Into::into).collect(), + secret: value.secret.expose_secret().to_string().into(), + unrestricted: value.unrestricted, + } + } +} + +impl From + for provider_types::ApplicationCredentialCreateBuilder +{ + fn from(value: api_types_application_credential::ApplicationCredentialCreate) -> Self { + let mut builder = provider_types::ApplicationCredentialCreateBuilder::default(); + builder.name(value.name); + builder.roles(value.roles.into_iter().map(Into::into).collect::>()); + if let Some(v) = value.access_rules { + builder.access_rules( + v.into_iter() + .map(|r| { + provider_types::AccessRuleCreateBuilder::from(r) + .build() + .unwrap() + }) + .collect::>(), + ); + } + if let Some(v) = value.description { + builder.description(v); + } + if let Some(v) = value.expires_at { + builder.expires_at(v); + } + if let Some(v) = value.unrestricted { + builder.unrestricted(v); + } + if let Some(v) = value.secret { + builder.secret(v); + } + builder + } +} + +impl From for provider_types::AccessRuleCreateBuilder { + fn from(value: api_types_access_rule::AccessRuleCreate) -> Self { + let mut builder = provider_types::AccessRuleCreateBuilder::default(); + if let Some(v) = value.id { + builder.id(v); + } + if let Some(v) = value.method { + builder.method(v); + } + if let Some(v) = value.path { + builder.path(v); + } + if let Some(v) = value.service { + builder.service(v); + } + builder + } +} + +impl From + for provider_types::ApplicationCredentialListParametersBuilder +{ + fn from(value: api_types_application_credential::ApplicationCredentialListParameters) -> Self { + let mut builder = provider_types::ApplicationCredentialListParametersBuilder::default(); + if let Some(v) = value.name { + builder.name(v); + } + builder + } +} + +impl PartialEq for api_types_application_credential::ApplicationCredentialCreated { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.name == other.name + && self.project_id == other.project_id + && self.unrestricted == other.unrestricted + && self.roles == other.roles + && self.description == other.description + && self.expires_at == other.expires_at + && self.access_rules == other.access_rules + } +} + +impl PartialEq for api_types_application_credential::ApplicationCredentialCreate { + fn eq(&self, other: &Self) -> bool { + self.access_rules == other.access_rules + && self.description == other.description + && self.expires_at == other.expires_at + && self.name == other.name + && self.roles == other.roles + && self.unrestricted == other.unrestricted + // secret is intentionally excluded — SecretBox does not implement PartialEq + } +} diff --git a/crates/appcred-driver-sql/src/application_credential.rs b/crates/appcred-driver-sql/src/application_credential.rs index c555c42d9..fa1cb0dfd 100644 --- a/crates/appcred-driver-sql/src/application_credential.rs +++ b/crates/appcred-driver-sql/src/application_credential.rs @@ -26,13 +26,13 @@ use crate::entity::{ pub mod access_rule; mod create; +mod delete; mod get; mod list; - pub use create::create; +pub use delete::delete; pub use get::get; pub use list::list; - impl TryFrom for ApplicationCredentialBuilder { type Error = ApplicationCredentialProviderError; diff --git a/crates/appcred-driver-sql/src/application_credential/delete.rs b/crates/appcred-driver-sql/src/application_credential/delete.rs new file mode 100644 index 000000000..6a5606a69 --- /dev/null +++ b/crates/appcred-driver-sql/src/application_credential/delete.rs @@ -0,0 +1,86 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Delete application credential +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use openstack_keystone_core::application_credential::ApplicationCredentialProviderError; +use openstack_keystone_core::error::DbContextExt; + +use crate::entity::{ + application_credential as db_application_credential, + prelude::ApplicationCredential as DbApplicationCredential, +}; + +pub async fn delete( + db: &DatabaseConnection, + id: &str, +) -> Result<(), ApplicationCredentialProviderError> { + let res = DbApplicationCredential::delete_many() + .filter(db_application_credential::Column::Id.eq(id)) + .exec(db) + .await + .context("deleting application credential")?; + if res.rows_affected == 1 { + Ok(()) + } else { + Err(ApplicationCredentialProviderError::ApplicationCredentialNotFound(id.to_string())) + } +} +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + + use super::super::tests::*; + use super::*; + + #[tokio::test] + async fn test_delete() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .into_connection(); + + delete(&db, "app_cred_id").await.unwrap(); + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "application_credential" WHERE "application_credential"."id" = $1"#, + ["app_cred_id".into()] + )] + ); + } + + #[tokio::test] + async fn test_delete_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 0, + }]) + .into_connection(); + + let result = delete(&db, "non-existing-id").await; + + assert!(matches!( + result, + Err(ApplicationCredentialProviderError::ApplicationCredentialNotFound(_)) + )); + } +} diff --git a/crates/appcred-driver-sql/src/lib.rs b/crates/appcred-driver-sql/src/lib.rs index 419a4968d..7963799cc 100644 --- a/crates/appcred-driver-sql/src/lib.rs +++ b/crates/appcred-driver-sql/src/lib.rs @@ -112,6 +112,22 @@ impl ApplicationCredentialBackend for SqlBackend { application_credential::access_rule::delete(&state.db, user_id, id).await } + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError> { + application_credential::delete(&state.db, id).await + } /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/backend.rs b/crates/core/src/application_credential/backend.rs index ba720fbf0..2604b7805 100644 --- a/crates/core/src/application_credential/backend.rs +++ b/crates/core/src/application_credential/backend.rs @@ -73,6 +73,21 @@ pub trait ApplicationCredentialBackend: Send + Sync { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `id`: The ID of the application credential to delete. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + state: &ServiceState, + id: &'a str, + ) -> Result<(), ApplicationCredentialProviderError>; + /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/provider_api.rs b/crates/core/src/application_credential/provider_api.rs index c0772546b..0372d792e 100644 --- a/crates/core/src/application_credential/provider_api.rs +++ b/crates/core/src/application_credential/provider_api.rs @@ -70,6 +70,21 @@ pub trait ApplicationCredentialApi: Send + Sync { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `rec`: The application credential deletion request. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + rec: ApplicationCredential, + ) -> Result<(), ApplicationCredentialProviderError>; + /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/application_credential/service.rs b/crates/core/src/application_credential/service.rs index d3c4b3bc5..d451ddacd 100644 --- a/crates/core/src/application_credential/service.rs +++ b/crates/core/src/application_credential/service.rs @@ -290,6 +290,54 @@ impl ApplicationCredentialApi for ApplicationCredentialService { Ok(()) } + /// Delete an application credential by ID. + /// + /// # Parameters + /// - `state`: The current service state. + /// - `rec`: The application credential deletion request. + /// + /// # Returns + /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or + /// an error. + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + rec: ApplicationCredential, + ) -> Result<(), ApplicationCredentialProviderError> { + if let Some(vsc) = ctx.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &ctx.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Delete, + EventPayload::ApplicationCredential { id: rec.id.to_string(), project_id: rec.project_id.to_string() } , + ), + operation: async { + backend_driver.delete_application_credential(ctx.state(), &rec.id).await + }, + on_audit_error: |_: AuditDispatchError| { + ApplicationCredentialProviderError::Driver("audit dispatch failed".into()) + }, + }?; + } else { + self.backend_driver + .delete_application_credential(ctx.state(), &rec.id) + .await?; + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::ApplicationCredential { + id: rec.id.to_string(), + project_id: rec.project_id.to_string(), + }, + )) + .await; + } + + Ok(()) + } /// Get a user's access rule by its ID. /// /// # Parameters diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index d405f037f..4373bb1e3 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -437,6 +437,12 @@ mod application_credential { id: &'a str, ) -> Result<(), ApplicationCredentialProviderError>; + async fn delete_application_credential<'a>( + &self, + ctx: &ExecutionContext<'a>, + rec: ApplicationCredential, + ) -> Result<(), ApplicationCredentialProviderError>; + async fn get_access_rule<'a>( &self, ctx: &ExecutionContext<'a>, diff --git a/crates/keystone/src/api/v3/user/application_credential/create.rs b/crates/keystone/src/api/v3/user/application_credential/create.rs new file mode 100644 index 000000000..093897571 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -0,0 +1,401 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use validator::Validate; + +use super::types::application_credential::{ + ApplicationCredentialCreateRequest, ApplicationCredentialCreateResponse, +}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::application_credential as core_type_application_credential; +use openstack_keystone_core_types::auth::ScopeInfo; +/// Create application credential. +/// +/// POST /v3/users/{user_id}/application_credentials +#[utoipa::path( + post, + path = "/", + request_body = ApplicationCredentialCreateRequest, + responses( + (status = CREATED, description = "Application credential created", body = ApplicationCredentialCreateResponse), + (status = 400, description = "Bad request — validation error or role not found"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 409, description = "Conflict — application credential already exists"), + ), + tag = "application_credentials" +)] +pub(super) async fn create( + Auth(user_auth): Auth, + Path(user_id): Path, + State(state): State, + Json(payload): Json, +) -> Result { + payload.validate()?; + let execution_context = ExecutionContext::from_auth(&state, &user_auth); + + // project_id must come from the token scope, not the request body + let project_id = match user_auth.authorization().map(|a| &a.scope) { + Some(ScopeInfo::Project { project, .. }) => project.id.clone(), + _ => { + return Err(KeystoneApiError::BadRequest( + "application credentials require a project-scoped token".into(), + )); + } + }; + + let mut target = serde_json::to_value(&payload.application_credential)?; + target["user_id"] = json!(user_id); + // Check payload has secret field,if yes copy target variable and remove secret from copied variable and pass it to policy enforcer + let mut target_for_policy = target.clone(); + if let Some(obj) = target_for_policy.as_object_mut() { + obj.remove("secret"); + } + + state + .policy_enforcer + .enforce( + "identity/user/application_credential/create", + &user_auth, + json!({"application_credential": target_for_policy}), + None, + ) + .await?; + + let app_cred = core_type_application_credential::ApplicationCredentialCreateBuilder::from( + payload.application_credential, + ) + .user_id(user_id.clone()) + .project_id(project_id) + .build()?; + + // Verify user exists — 404 if not found + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + let created = state + .provider + .get_application_credential_provider() + .create_application_credential(&execution_context, app_cred) + .await + .map_err(KeystoneApiError::from)?; + + Ok(( + StatusCode::CREATED, + Json(ApplicationCredentialCreateResponse { + application_credential: created.into(), + }), + )) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialCreateResponseBuilder; + use openstack_keystone_core_types::identity::*; + use secrecy::{ExposeSecret, SecretString}; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::{ + ApplicationCredentialCreateBuilder, ApplicationCredentialCreateRequest, + ApplicationCredentialCreateResponse, + }; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_create_response() + -> openstack_keystone_core_types::application_credential::ApplicationCredentialCreateResponse + { + ApplicationCredentialCreateResponseBuilder::default() + .id("new-cred-id") + .name("my-cred") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .secret(SecretString::new("generated-secret".into())) + .build() + .unwrap() + } + + fn request_body() -> String { + let req = ApplicationCredentialCreateRequest { + application_credential: ApplicationCredentialCreateBuilder::default() + .name("my-cred") + .roles(vec![]) + .build() + .unwrap(), + }; + serde_json::to_string(&req).unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| Ok(mock_create_response())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialCreateResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credential.id, "new-cred-id"); + assert_eq!(res.application_credential.name, "my-cred"); + // secret must be present in create response + assert!(!res.application_credential.secret.expose_secret().is_empty()); + } + + #[traced_test] + #[tokio::test] + async fn test_create_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_create_role_not_found() { + use openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError; + + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| { + Err(ApplicationCredentialProviderError::RoleNotFound( + "role-id".to_string(), + )) + }); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + // Role not found → 404 per OpenStack spec + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_create_conflict() { + use openstack_keystone_core_types::application_credential::ApplicationCredentialProviderError; + + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_create_application_credential() + .returning(|_, _| { + Err(ApplicationCredentialProviderError::Conflict( + "already exists".to_string(), + )) + }); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + #[traced_test] + #[tokio::test] + async fn test_create_not_allowed() { + let vsc = test_fixture_scoped(); + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .extension(vsc) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_create_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/uid/application_credentials") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(request_body())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/delete.rs b/crates/keystone/src/api/v3/user/application_credential/delete.rs new file mode 100644 index 000000000..6b3fd54aa --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -0,0 +1,295 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +#[utoipa::path( + delete, + path = "/{application_credential_id}", + responses( + (status = NO_CONTENT, description = "Application credential deleted"), + (status = 404, description = "Application credential or user not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn delete( + Auth(user_auth): Auth, + Path((user_id, application_credential_id)): Path<(String, String)>, + State(state): State, +) -> Result { + let execution_context = ExecutionContext::from_auth(&state, &user_auth); + + // Fetch credential first — needed for policy and audit + let current = state + .provider + .get_application_credential_provider() + .get_application_credential(&execution_context, &application_credential_id) + .await + .map_err(KeystoneApiError::from)? + .ok_or_else(|| { + KeystoneApiError::not_found("application_credential", &application_credential_id) + })?; + + // Policy check — uses credential's real user_id, not URL parameter + state + .policy_enforcer + .enforce( + "identity/user/application_credential/delete", + &user_auth, + serde_json::Value::Null, + Some(json!({"application_credential": serde_json::to_value(¤t)?})), + ) + .await?; + + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + state + .provider + .get_application_credential_provider() + .delete_application_credential(&execution_context, current) + .await + .map_err(KeystoneApiError::from)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credential() + -> openstack_keystone_core_types::application_credential::ApplicationCredential { + openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder::default() + .id("cred-id").name("test-cred").user_id("uid") + .project_id("pid").unrestricted(false).roles(vec![]) + .build().unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_delete() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + app_cred_mock + .expect_delete_application_credential() + .returning(|_, _| Ok(())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_credential_not_found() { + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/non-existing") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_not_allowed() { + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_application_credential(app_cred_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_delete_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/uid/application_credentials/cred-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/list.rs b/crates/keystone/src/api/v3/user/application_credential/list.rs new file mode 100644 index 000000000..36ea84876 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -0,0 +1,316 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use validator::Validate; + +use super::types::application_credential::{ + ApplicationCredentialList, ApplicationCredentialListParameters, +}; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::application_credential as core_type_application_credential; + +#[utoipa::path( + get, + path = "/", + params(ApplicationCredentialListParameters), + responses( + (status = OK, description = "List of application credentials", body = ApplicationCredentialList), + (status = 404, description = "User not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn list( + Auth(user_auth): Auth, + Path(user_id): Path, + Query(payload): Query, + State(state): State, +) -> Result { + payload.validate()?; + let execution_context = ExecutionContext::from_auth(&state, &user_auth); + + // Policy check first + let mut target = serde_json::to_value(&payload)?; + target["user_id"] = json!(user_id); + state + .policy_enforcer + .enforce( + "identity/user/application_credential/list", + &user_auth, + json!({"application_credential": target}), + None, + ) + .await?; + + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + let filter = + core_type_application_credential::ApplicationCredentialListParametersBuilder::from(payload) + .user_id(user_id.clone()) + .build() + .unwrap(); + let application_credentials = state + .provider + .get_application_credential_provider() + .list_application_credentials(&execution_context, &filter) + .await + .map_err(KeystoneApiError::from)?; + + Ok(( + StatusCode::OK, + Json(ApplicationCredentialList { + application_credentials: application_credentials + .into_iter() + .map(Into::into) + .collect(), + }), + )) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder as CoreApplicationCredentialBuilder; + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::ApplicationCredentialList; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credentials() + -> Vec { + vec![ + CoreApplicationCredentialBuilder::default() + .id("cred-1") + .name("first") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + CoreApplicationCredentialBuilder::default() + .id("cred-2") + .name("second") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + ] + } + + #[traced_test] + #[tokio::test] + async fn test_list() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_list_application_credentials() + .returning(|_, _| Ok(mock_credentials())); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialList = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credentials.len(), 2); + assert_eq!(res.application_credentials[0].id, "cred-1"); + assert_eq!(res.application_credentials[1].id, "cred-2"); + } + + #[traced_test] + #[tokio::test] + async fn test_list_empty() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_list_application_credentials() + .returning(|_, _| Ok(vec![])); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialList = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.application_credentials.len(), 0); + } + + #[traced_test] + #[tokio::test] + async fn test_list_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_list_not_allowed() { + let vsc = test_fixture_scoped(); + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_list_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/mod.rs b/crates/keystone/src/api/v3/user/application_credential/mod.rs new file mode 100644 index 000000000..33697a680 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/mod.rs @@ -0,0 +1,42 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use utoipa::OpenApi; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::keystone::ServiceState; + +mod create; +mod delete; +mod list; +mod show; +pub mod types; + +/// OpenApi specification for the application-credential API. +#[derive(OpenApi)] +#[openapi( + tags( + (name="application_credentials", + description=r#"Application Credentials are a way to authenticate to the OpenStack Identity service without using a user's password. They are useful for applications that need to interact with OpenStack services. +"#), + ) +)] +pub struct ApiDoc; + +pub(crate) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(create::create)) + .routes(routes!(delete::delete)) + .routes(routes!(list::list)) + .routes(routes!(show::show)) +} diff --git a/crates/keystone/src/api/v3/user/application_credential/show.rs b/crates/keystone/src/api/v3/user/application_credential/show.rs new file mode 100644 index 000000000..4842c60d7 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -0,0 +1,311 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +use super::types::application_credential::ApplicationCredentialResponse; +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +#[utoipa::path( + get, + path = "/{application_credential_id}", + params(), + responses( + (status = OK, description = "Single application credential", body = ApplicationCredentialResponse), + (status = 404, description = "Application credential or user not found"), + (status = 403, description = "Forbidden"), + (status = 401, description = "Unauthorized"), + ), + tag = "application_credentials" +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path((user_id, application_credential_id)): Path<(String, String)>, + State(state): State, +) -> Result { + let execution_context = ExecutionContext::from_auth(&state, &user_auth); + + // Fetch credential first — needed for policy + let current = state + .provider + .get_application_credential_provider() + .get_application_credential(&execution_context, &application_credential_id) + .await + .map_err(KeystoneApiError::from)? + .ok_or_else(|| { + KeystoneApiError::not_found("application_credential", &application_credential_id) + })?; + + // Policy check — uses stored object's real user_id + state + .policy_enforcer + .enforce( + "identity/user/application_credential/show", + &user_auth, + serde_json::Value::Null, + Some(json!({"application_credential": serde_json::to_value(¤t)?})), + ) + .await?; + + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + Ok(( + StatusCode::OK, + Json(ApplicationCredentialResponse { + application_credential: current.into(), + }), + )) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use openstack_keystone_core_types::application_credential::ApplicationCredentialBuilder as CoreApplicationCredentialBuilder; + use openstack_keystone_core_types::identity::*; + + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::v3::openapi_router; + use crate::api::v3::user::application_credential::types::application_credential::{ + ApplicationCredentialBuilder as ApiApplicationCredentialBuilder, + ApplicationCredentialResponse, + }; + use crate::application_credential::MockApplicationCredentialProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + + fn mock_user(mock: &mut MockIdentityProvider) { + mock.expect_get_user().returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("uid") + .domain_id("did") + .enabled(true) + .name("test_user") + .build() + .unwrap(), + )) + }); + } + + fn mock_credential() + -> openstack_keystone_core_types::application_credential::ApplicationCredential { + CoreApplicationCredentialBuilder::default() + .id("existing-id") + .name("test-cred") + .user_id("uid") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap() + } + + #[traced_test] + #[tokio::test] + async fn test_show() { + let mut identity_mock = MockIdentityProvider::default(); + mock_user(&mut identity_mock); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .withf(|_, id: &'_ str| id == "existing-id") + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ApplicationCredentialResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiApplicationCredentialBuilder::default() + .id("existing-id") + .name("test-cred") + .project_id("pid") + .unrestricted(false) + .roles(vec![]) + .build() + .unwrap(), + res.application_credential, + ); + } + + #[traced_test] + #[tokio::test] + async fn test_show_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_show_credential_not_found() { + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(None)); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_application_credential(app_cred_mock), + true, + None, + ) + .await; + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/non-existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[traced_test] + #[tokio::test] + async fn test_show_not_allowed() { + let mut app_cred_mock = MockApplicationCredentialProvider::default(); + app_cred_mock + .expect_get_application_credential() + .returning(|_, _| Ok(Some(mock_credential()))); + + let vsc = test_fixture_scoped(); + let state = get_mocked_state( + Provider::mocked_builder().mock_application_credential(app_cred_mock), + false, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[traced_test] + #[tokio::test] + async fn test_show_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .uri("/users/uid/application_credentials/existing-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v3/user/application_credential/types.rs b/crates/keystone/src/api/v3/user/application_credential/types.rs new file mode 100644 index 000000000..4d76c0572 --- /dev/null +++ b/crates/keystone/src/api/v3/user/application_credential/types.rs @@ -0,0 +1,15 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +pub use openstack_keystone_api_types::v3::application_credential::*; diff --git a/crates/keystone/src/api/v3/user/mod.rs b/crates/keystone/src/api/v3/user/mod.rs index e8107f014..323a80709 100644 --- a/crates/keystone/src/api/v3/user/mod.rs +++ b/crates/keystone/src/api/v3/user/mod.rs @@ -16,6 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::keystone::ServiceState; +pub mod application_credential; mod create; mod delete; mod groups; @@ -34,6 +35,10 @@ pub(super) fn openapi_router() -> OpenApiRouter { .routes(routes!(password::change_password)) .routes(routes!(groups::groups)) .merge(os_ec2::openapi_router()) + .nest( + "/{user_id}/application_credentials", + application_credential::openapi_router(), + ) } #[cfg(test)] diff --git a/policy/user/application_credential/create.rego b/policy/user/application_credential/create.rego new file mode 100644 index 000000000..53d3f9dcf --- /dev/null +++ b/policy/user/application_credential/create.rego @@ -0,0 +1,13 @@ +# METADATA +# description: Policy for creating application credentials +package identity.user.application_credential.create + +default allow := false + +allow if { + input.credentials.user_id == input.target.application_credential.user_id +} + +violation contains {"field": "user_id", "msg": "creating application credentials for a different user is not allowed."} if { + input.credentials.user_id != input.target.application_credential.user_id +} \ No newline at end of file diff --git a/policy/user/application_credential/create_test.rego b/policy/user/application_credential/create_test.rego new file mode 100644 index 000000000..a7a9e76cc --- /dev/null +++ b/policy/user/application_credential/create_test.rego @@ -0,0 +1,34 @@ +package test_application_credential_create + +import data.identity.user.application_credential.create + +test_owner_allowed if { + create.allow with input as { + "credentials": {"user_id": "uid"}, + "target": {"application_credential": {"user_id": "uid"}}, + } +} + +test_owner_no_violation if { + count(create.violation) == 0 with input as { + "credentials": {"user_id": "uid"}, + "target": {"application_credential": {"user_id": "uid"}}, + } +} + +test_non_owner_forbidden if { + not create.allow with input as { + "credentials": {"user_id": "other"}, + "target": {"application_credential": {"user_id": "uid"}}, + } +} + +test_non_owner_violation if { + violation := create.violation with input as { + "credentials": {"user_id": "other"}, + "target": {"application_credential": {"user_id": "uid"}}, + } + count(violation) == 1 + some v in violation + v.field == "user_id" +} \ No newline at end of file diff --git a/policy/user/application_credential/delete.rego b/policy/user/application_credential/delete.rego new file mode 100644 index 000000000..c1e6cca89 --- /dev/null +++ b/policy/user/application_credential/delete.rego @@ -0,0 +1,14 @@ +# METADATA +# description: Policy for deleting application credentials +package identity.user.application_credential.delete + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { input.credentials.user_id == input.existing.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "deleting application credentials of a different user is not allowed."} if { + not input.credentials.is_admin + input.credentials.user_id != input.existing.application_credential.user_id +} \ No newline at end of file diff --git a/policy/user/application_credential/delete_test.rego b/policy/user/application_credential/delete_test.rego new file mode 100644 index 000000000..a0d88ee69 --- /dev/null +++ b/policy/user/application_credential/delete_test.rego @@ -0,0 +1,22 @@ +package test_application_credential_delete + +import data.identity.user.application_credential.delete + +test_admin_allowed if { + delete.allow with input as {"credentials": {"is_admin": true}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_owner_allowed if { + delete.allow with input as {"credentials": {"user_id": "uid"}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_forbidden if { + not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := delete.violation with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" +} \ No newline at end of file diff --git a/policy/user/application_credential/list.rego b/policy/user/application_credential/list.rego new file mode 100644 index 000000000..29918a96b --- /dev/null +++ b/policy/user/application_credential/list.rego @@ -0,0 +1,25 @@ +# METADATA +# description: Policy for listing application credentials +package identity.user.application_credential.list + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { + "reader" in input.credentials.roles + input.credentials.system == "all" +} + +allow if { input.credentials.user_id == input.target.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "listing application credentials requires a user_id."} if { + not input.target.application_credential.user_id +} + +violation contains {"field": "user_id", "msg": "listing application credentials of a different user is not allowed."} if { + input.target.application_credential.user_id + not input.credentials.is_admin + not input.credentials.system == "all" + input.credentials.user_id != input.target.application_credential.user_id +} \ No newline at end of file diff --git a/policy/user/application_credential/list_test.rego b/policy/user/application_credential/list_test.rego new file mode 100644 index 000000000..4408c208a --- /dev/null +++ b/policy/user/application_credential/list_test.rego @@ -0,0 +1,38 @@ +package test_application_credential_list + +import data.identity.user.application_credential.list + +test_admin_allowed if { + list.allow with input as {"credentials": {"is_admin": true}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_system_reader_allowed if { + list.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_owner_allowed if { + list.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_forbidden if { + not list.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := list.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" + e.msg == "listing application credentials of a different user is not allowed." +} + +test_missing_user_id_forbidden if { + not list.allow with input as {"credentials": {"user_id": "uid", "roles": []}, "target": {"application_credential": {}}} +} + +test_missing_user_id_violation if { + v := list.violation with input as {"credentials": {"user_id": "uid", "roles": []}, "target": {"application_credential": {}}} + some e in v + e.field == "user_id" + e.msg == "listing application credentials requires a user_id." +} \ No newline at end of file diff --git a/policy/user/application_credential/show.rego b/policy/user/application_credential/show.rego new file mode 100644 index 000000000..beecf37f5 --- /dev/null +++ b/policy/user/application_credential/show.rego @@ -0,0 +1,20 @@ +# METADATA +# description: Policy for showing application credential details +package identity.user.application_credential.show + +default allow := false + +allow if { input.credentials.is_admin } + +allow if { + "reader" in input.credentials.roles + input.credentials.system == "all" +} + +allow if { input.credentials.user_id == input.existing.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "viewing application credentials of a different user is not allowed."} if { + not input.credentials.is_admin + not input.credentials.system == "all" + input.credentials.user_id != input.existing.application_credential.user_id +} \ No newline at end of file diff --git a/policy/user/application_credential/show_test.rego b/policy/user/application_credential/show_test.rego new file mode 100644 index 000000000..7cce633cf --- /dev/null +++ b/policy/user/application_credential/show_test.rego @@ -0,0 +1,26 @@ +package test_application_credential_show + +import data.identity.user.application_credential.show + +test_admin_allowed if { + show.allow with input as {"credentials": {"is_admin": true}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_system_reader_allowed if { + show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_owner_allowed if { + show.allow with input as {"credentials": {"user_id": "uid"}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_forbidden if { + not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := show.violation with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" +} \ No newline at end of file diff --git a/tests/api/src/identity.rs b/tests/api/src/identity.rs index 1b33e6da7..69dfc3149 100644 --- a/tests/api/src/identity.rs +++ b/tests/api/src/identity.rs @@ -11,5 +11,6 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +pub mod application_credential; pub mod group; pub mod user; diff --git a/tests/api/src/identity/application_credential.rs b/tests/api/src/identity/application_credential.rs new file mode 100644 index 000000000..6eefd0937 --- /dev/null +++ b/tests/api/src/identity/application_credential.rs @@ -0,0 +1,188 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::guard::*; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use openstack_sdk::api::rest_endpoint_prelude::*; +use openstack_sdk::{AsyncOpenStack, api::QueryAsync}; +use std::borrow::Cow; +use std::sync::Arc; + +struct AppCredCreateRequest { + user_id: String, + app_cred: ApplicationCredentialCreate, +} + +impl RestEndpoint for AppCredCreateRequest { + fn method(&self) -> http::Method { + http::Method::POST + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials", self.user_id).into() + } + fn body(&self) -> Result)>, BodyError> { + let mut params = JsonBodyParams::default(); + params.push( + "application_credential", + serde_json::to_value(&self.app_cred)?, + ); + params.into_body() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credential".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +struct AppCredDeleteRequest { + user_id: String, + id: String, +} + +impl RestEndpoint for AppCredDeleteRequest { + fn method(&self) -> http::Method { + http::Method::DELETE + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials/{}", self.user_id, self.id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +pub struct DeletableApplicationCredential { + pub credential: ApplicationCredentialCreated, + pub user_id: String, +} + +#[async_trait::async_trait] +impl DeletableResource for DeletableApplicationCredential { + async fn delete(&self, state: &Arc) -> Result<()> { + Ok(openstack_sdk::api::ignore(AppCredDeleteRequest { + user_id: self.user_id.clone(), + id: self.credential.id.clone(), + }) + .query_async(state.as_ref()) + .await?) + } +} + +impl std::ops::Deref for DeletableApplicationCredential { + type Target = ApplicationCredentialCreated; + fn deref(&self) -> &Self::Target { + &self.credential + } +} + +pub async fn create_application_credential( + tc: &Arc, + user_id: &str, + app_cred: ApplicationCredentialCreate, +) -> Result> { + let obj: ApplicationCredentialCreated = AppCredCreateRequest { + user_id: user_id.to_string(), + app_cred, + } + .query_async(tc.as_ref()) + .await?; + Ok(AsyncResourceGuard::new( + DeletableApplicationCredential { + credential: obj, + user_id: user_id.to_string(), + }, + tc.clone(), + )) +} + +struct AppCredGetRequest { + user_id: String, + id: String, +} + +impl RestEndpoint for AppCredGetRequest { + fn method(&self) -> http::Method { + http::Method::GET + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials/{}", self.user_id, self.id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credential".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +struct AppCredListRequest { + user_id: String, +} + +impl RestEndpoint for AppCredListRequest { + fn method(&self) -> http::Method { + http::Method::GET + } + fn endpoint(&self) -> Cow<'static, str> { + format!("users/{}/application_credentials", self.user_id).into() + } + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + fn response_key(&self) -> Option> { + Some("application_credentials".into()) + } + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } +} + +pub async fn get_application_credential( + tc: &Arc, + user_id: &str, + id: &str, +) -> Result { + use openstack_keystone_api_types::v3::application_credential::application_credential::ApplicationCredential; + let obj: ApplicationCredential = AppCredGetRequest { + user_id: user_id.to_string(), + id: id.to_string(), + } + .query_async(tc.as_ref()) + .await?; + Ok(obj) +} + +pub async fn list_application_credentials( + tc: &Arc, + user_id: &str, +) -> Result> { + use openstack_keystone_api_types::v3::application_credential::application_credential::ApplicationCredential; + let objs: Vec = AppCredListRequest { + user_id: user_id.to_string(), + } + .query_async(tc.as_ref()) + .await?; + Ok(objs) +} diff --git a/tests/api/tests/api_v3/identity.rs b/tests/api/tests/api_v3/identity.rs index 3b0e69e09..63a57dce9 100644 --- a/tests/api/tests/api_v3/identity.rs +++ b/tests/api/tests/api_v3/identity.rs @@ -11,5 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + +mod application_credential; mod group; mod user; diff --git a/tests/api/tests/api_v3/identity/application_credential.rs b/tests/api/tests/api_v3/identity/application_credential.rs new file mode 100644 index 000000000..b24baf55f --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential.rs @@ -0,0 +1,18 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +mod create; +mod delete; +mod list; +mod show; diff --git a/tests/api/tests/api_v3/identity/application_credential/create.rs b/tests/api/tests/api_v3/identity/application_credential/create.rs new file mode 100644 index 000000000..385b4e7ca --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -0,0 +1,77 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use secrecy::ExposeSecret; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::create_application_credential; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_create() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + assert!(cred.name.starts_with("test-cred-")); + assert!(!cred.secret.expose_secret().is_empty()); + assert_eq!(cred.user_id, user_id); + + cred.delete().await?; + Ok(()) +} + +#[tokio::test] +#[traced_test] +async fn test_create_with_description() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .description("my description") + .roles(vec![]) + .build()?, + ) + .await?; + + assert_eq!(cred.description, Some("my description".to_string())); + + cred.delete().await?; + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/delete.rs b/tests/api/tests/api_v3/identity/application_credential/delete.rs new file mode 100644 index 000000000..a99c8d89a --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/delete.rs @@ -0,0 +1,52 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_delete() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + let cred_id = cred.id.clone(); + cred.delete().await?; + + // Verify it no longer exists + let result = get_application_credential(&tc, &user_id, &cred_id).await; + assert!(result.is_err()); + + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/list.rs b/tests/api/tests/api_v3/identity/application_credential/list.rs new file mode 100644 index 000000000..e74aadea7 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -0,0 +1,71 @@ +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use std::sync::Arc; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, list_application_credentials, +}; +use tracing_test::traced_test; + +pub async fn get_project_scoped_client() -> Result> { + let mut tc = AsyncOpenStack::new(&CloudConfig::from_env()?).await?; + + tc.authorize( + Some(openstack_sdk::auth::authtoken::AuthTokenScope::Project( + openstack_sdk::types::identity::v3::Project { + id: None, + name: Some("admin".to_string()), + domain: Some(openstack_sdk::types::identity::v3::Domain { + id: Some("default".to_string()), + name: None, + }), + }, + )), + false, + false, + ) + .await?; + + Ok(Arc::new(tc)) +} + +#[tokio::test] +#[traced_test] +async fn test_list() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred1 = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + let cred2 = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + let list = list_application_credentials(&tc, &user_id).await?; + assert!(list.iter().any(|c| c.id == cred1.id)); + assert!(list.iter().any(|c| c.id == cred2.id)); + + cred1.delete().await?; + cred2.delete().await?; + Ok(()) +} diff --git a/tests/api/tests/api_v3/identity/application_credential/show.rs b/tests/api/tests/api_v3/identity/application_credential/show.rs new file mode 100644 index 000000000..efe894a43 --- /dev/null +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -0,0 +1,51 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +use crate::api_v3::identity::application_credential::list::get_project_scoped_client; +use eyre::Result; +use openstack_keystone_api_types::v3::application_credential::application_credential::*; +use test_api::guard::ResourceGuard; +use test_api::identity::application_credential::{ + create_application_credential, get_application_credential, +}; +use tracing_test::traced_test; + +#[tokio::test] +#[traced_test] +async fn test_show() -> Result<()> { + let tc = get_project_scoped_client().await?; + let user_id = tc + .get_auth_info() + .ok_or_else(|| eyre::eyre!("no auth info available"))? + .token + .user + .id; + + let cred = create_application_credential( + &tc, + &user_id, + ApplicationCredentialCreateBuilder::default() + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) + .roles(vec![]) + .build()?, + ) + .await?; + + let fetched = get_application_credential(&tc, &user_id, &cred.id).await?; + + assert_eq!(fetched.id, cred.id); + assert!(fetched.name.starts_with("test-cred")); + + cred.delete().await?; + Ok(()) +}