From 3f62eb92cbf2ba722faa56a6d2ebfc38bbfe1020 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Wed, 23 Apr 2025 09:29:43 +0200 Subject: [PATCH] feat: Add identity provider --- Cargo.toml | 5 +- migration/src/lib.rs | 6 +- migration/src/m20250301_000001_passkey.rs | 2 +- migration/src/m20250414_000001_idp.rs | 95 +++ src/api/common.rs | 30 +- src/api/error.rs | 18 +- src/api/v3/auth/token/common.rs | 65 +- src/api/v3/auth/token/mod.rs | 66 +- src/api/v3/federation/mod.rs | 569 ++++++++++++++++++ src/api/v3/federation/types.rs | 17 + .../v3/federation/types/identity_provider.rs | 266 ++++++++ src/api/v3/mod.rs | 5 + src/api/v3/role/mod.rs | 28 +- src/api/v3/role_assignment/mod.rs | 28 +- src/assignment/backends/sql/implied_role.rs | 2 - src/config.rs | 9 + src/db/entity.rs | 19 + src/db/entity/federated_identity_provider.rs | 41 ++ src/db/entity/prelude.rs | 1 + src/error.rs | 7 + src/federation/backends.rs | 16 + src/federation/backends/error.rs | 41 ++ src/federation/backends/sql.rs | 93 +++ .../backends/sql/identity_provider.rs | 445 ++++++++++++++ src/federation/error.rs | 53 ++ src/federation/mod.rs | 200 ++++++ src/federation/types.rs | 68 +++ src/federation/types/identity_provider.rs | 94 +++ src/lib.rs | 1 + src/plugin_manager.rs | 12 + src/provider.rs | 32 + src/tests/api.rs | 47 +- src/token/mod.rs | 24 +- 33 files changed, 2232 insertions(+), 173 deletions(-) create mode 100644 migration/src/m20250414_000001_idp.rs create mode 100644 src/api/v3/federation/mod.rs create mode 100644 src/api/v3/federation/types.rs create mode 100644 src/api/v3/federation/types/identity_provider.rs create mode 100644 src/db/entity/federated_identity_provider.rs create mode 100644 src/federation/backends.rs create mode 100644 src/federation/backends/error.rs create mode 100644 src/federation/backends/sql.rs create mode 100644 src/federation/backends/sql/identity_provider.rs create mode 100644 src/federation/error.rs create mode 100644 src/federation/mod.rs create mode 100644 src/federation/types.rs create mode 100644 src/federation/types/identity_provider.rs diff --git a/Cargo.toml b/Cargo.toml index 298004e7..61c44f3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "openstack_keystone" +description = "OpenStack Keystone service" version = "0.1.0" edition = "2024" license = "Apache-2.0" @@ -18,7 +19,7 @@ harness = false [dependencies] async-trait = { version = "^0.1" } axum = { version = "^0.8", features = ["macros"] } -base64 = "0.22.1" +base64 = { version = "^0.22" } bcrypt = { version = "0.17", features = ["alloc"] } bytes = { version = "^1.10" } chrono = { version = "^0.4" } @@ -52,7 +53,7 @@ webauthn-rs = { version = "^0.5", features = ["danger-allow-state-serialisation" criterion = { version = "^0.5", features = ["async_tokio"] } http-body-util = "^0.1" mockall = { version = "^0.13" } -sea-orm = { version = "*", features = ["mock"]} +sea-orm = { version = "^1.1", features = ["mock"]} tempfile = { version = "^3.19" } tracing-test = { version = "^0.2" } diff --git a/migration/src/lib.rs b/migration/src/lib.rs index a86e77bc..ccc54998 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,12 +1,16 @@ pub use sea_orm_migration::prelude::*; mod m20250301_000001_passkey; +mod m20250414_000001_idp; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![Box::new(m20250301_000001_passkey::Migration)] + vec![ + Box::new(m20250301_000001_passkey::Migration), + Box::new(m20250414_000001_idp::Migration), + ] } } diff --git a/migration/src/m20250301_000001_passkey.rs b/migration/src/m20250301_000001_passkey.rs index 3cd78927..908d3c1d 100644 --- a/migration/src/m20250301_000001_passkey.rs +++ b/migration/src/m20250301_000001_passkey.rs @@ -89,5 +89,5 @@ enum WebauthnState { UserId, State, CreatedAt, - Type + Type, } diff --git a/migration/src/m20250414_000001_idp.rs b/migration/src/m20250414_000001_idp.rs new file mode 100644 index 00000000..35b9550d --- /dev/null +++ b/migration/src/m20250414_000001_idp.rs @@ -0,0 +1,95 @@ +use openstack_keystone::db::entity::prelude::Project; +use openstack_keystone::db::entity::project; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(FederatedIdentityProvider::Table) + .if_not_exists() + .col(string_len(FederatedIdentityProvider::Id, 64).primary_key()) + .col(string_len(FederatedIdentityProvider::Name, 255)) + .col(string_len_null(FederatedIdentityProvider::DomainId, 64)) + .col(string_len_null( + FederatedIdentityProvider::OidcDiscoveryUrl, + 255, + )) + .col(string_len_null( + FederatedIdentityProvider::OidcClientId, + 255, + )) + .col(string_len_null( + FederatedIdentityProvider::OidcClientSecret, + 255, + )) + .col(string_len_null( + FederatedIdentityProvider::OidcResponseMode, + 64, + )) + .col(string_len_null( + FederatedIdentityProvider::OidcResponseTypes, + 255, + )) + .col(text_null(FederatedIdentityProvider::JwtValidationPubkeys)) + .col(string_len_null(FederatedIdentityProvider::BoundIssuer, 255)) + .col(json_null(FederatedIdentityProvider::ProviderConfig)) + .foreign_key( + ForeignKey::create() + .name("fk-user-passkey-credential") + .from( + FederatedIdentityProvider::Table, + FederatedIdentityProvider::DomainId, + ) + .to(Project, project::Column::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .index( + Index::create() + .unique() + .name("idx-idp-name-domain") + .col(FederatedIdentityProvider::DomainId) + .col(FederatedIdentityProvider::Name), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(FederatedIdentityProvider::Table) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum FederatedIdentityProvider { + Table, + Id, + DomainId, + Name, + OidcDiscoveryUrl, + OidcClientId, + OidcClientSecret, + OidcResponseMode, + OidcResponseTypes, + BoundIssuer, + JwtValidationPubkeys, + //JwksUrl, + //JwksCaPem, + ProviderConfig, +} diff --git a/src/api/common.rs b/src/api/common.rs index 4fca5607..960338f8 100644 --- a/src/api/common.rs +++ b/src/api/common.rs @@ -55,20 +55,14 @@ mod tests { use super::*; - use crate::assignment::MockAssignmentProvider; - use crate::catalog::MockCatalogProvider; use crate::config::Config; - use crate::identity::MockIdentityProvider; + use crate::keystone::Service; - use crate::provider::ProviderBuilder; + use crate::provider::Provider; use crate::resource::{MockResourceProvider, types::Domain}; - use crate::token::MockTokenProvider; #[tokio::test] async fn test_get_domain() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); - let mut resource_mock = MockResourceProvider::default(); resource_mock .expect_get_domain() @@ -90,21 +84,19 @@ mod tests { ..Default::default() })) }); - let identity_mock = MockIdentityProvider::default(); - let token_mock = MockTokenProvider::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) + let provider = Provider::mocked_builder() .resource(resource_mock) - .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); assert_eq!( "domain_id", diff --git a/src/api/error.rs b/src/api/error.rs index 3a2783c6..98dd230e 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -23,6 +23,7 @@ use tracing::error; use crate::assignment::error::AssignmentProviderError; use crate::catalog::error::CatalogProviderError; +use crate::federation::error::FederationProviderError; use crate::identity::error::IdentityProviderError; use crate::resource::error::ResourceProviderError; use crate::token::error::TokenProviderError; @@ -72,6 +73,12 @@ pub enum KeystoneApiError { source: CatalogProviderError, }, + #[error(transparent)] + Federation { + #[from] + source: FederationProviderError, + }, + #[error(transparent)] IdentityError { #[from] @@ -137,7 +144,7 @@ impl IntoResponse for KeystoneApiError { Json(json!({"error": {"code": StatusCode::UNAUTHORIZED.as_u16(), "message": self.to_string()}})), ).into_response() } - KeystoneApiError::InternalError(_) | KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } | KeystoneApiError::AssignmentError { .. } | KeystoneApiError::TokenError{..} => { + KeystoneApiError::InternalError(_) | KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } | KeystoneApiError::AssignmentError { .. } | KeystoneApiError::TokenError{..} | KeystoneApiError::Federation {..} => { (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), ).into_response() @@ -162,6 +169,15 @@ impl KeystoneApiError { _ => Self::AssignmentError { source }, } } + pub fn federation(source: FederationProviderError) -> Self { + match source { + FederationProviderError::IdentityProviderNotFound(x) => Self::NotFound { + resource: "identity provider".into(), + identifier: x, + }, + _ => Self::Federation { source }, + } + } pub fn identity(source: IdentityProviderError) -> Self { match source { IdentityProviderError::UserNotFound(x) => Self::NotFound { diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index 0e6f332d..f38ec61c 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -183,24 +183,21 @@ mod tests { MockAssignmentProvider, types::{Assignment, AssignmentType, Role as ProviderRole, RoleAssignmentListParameters}, }; - use crate::catalog::MockCatalogProvider; + use crate::config::Config; use crate::identity::{MockIdentityProvider, types::UserResponse}; use crate::keystone::Service; - use crate::provider::ProviderBuilder; + use crate::provider::Provider; use crate::resource::{ MockResourceProvider, types::{Domain, Project}, }; use crate::token::{ - DomainScopeToken, MockTokenProvider, ProjectScopeToken, Token as ProviderToken, - UnscopedToken, + DomainScopeToken, ProjectScopeToken, Token as ProviderToken, UnscopedToken, }; #[tokio::test] async fn test_from_unscoped() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -223,20 +220,20 @@ mod tests { ..Default::default() })) }); - let token_mock = MockTokenProvider::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) + let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) - .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let api_token = Token::from_provider_token( &state, @@ -255,8 +252,6 @@ mod tests { #[tokio::test] async fn test_from_domain_scoped() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -278,20 +273,20 @@ mod tests { ..Default::default() })) }); - let token_mock = MockTokenProvider::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) + let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) - .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let api_token = Token::from_provider_token( &state, @@ -315,8 +310,6 @@ mod tests { #[tokio::test] async fn test_from_project_scoped() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -347,9 +340,7 @@ mod tests { ..Default::default() })) }); - let token_mock = MockTokenProvider::default(); let mut assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); assignment_mock.expect_list_role_assignments().returning( |_, _, q: &RoleAssignmentListParameters| { Ok(vec![Assignment { @@ -362,17 +353,21 @@ mod tests { }]) }, ); - let provider = ProviderBuilder::default() - .config(config.clone()) + let provider = Provider::mocked_builder() .assignment(assignment_mock) - .catalog(catalog_mock) .identity(identity_mock) .resource(resource_mock) - .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let api_token = Token::from_provider_token( &state, diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index ef86246e..097c686d 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -288,7 +288,7 @@ mod tests { use crate::config::Config; use crate::identity::{MockIdentityProvider, types::UserResponse}; use crate::keystone::Service; - use crate::provider::ProviderBuilder; + use crate::provider::Provider; use crate::resource::{ MockResourceProvider, types::{Domain, Project}, @@ -298,10 +298,6 @@ mod tests { #[tokio::test] async fn test_get() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock.expect_get_user().returning(|_, id: &'_ str| { Ok(Some(UserResponse { @@ -338,17 +334,21 @@ mod tests { .expect_expand_domain_information() .returning(|_, _, _| Ok(())); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) + let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -389,10 +389,6 @@ mod tests { #[tokio::test] async fn test_get_allow_expired() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock.expect_get_user().returning(|_, id: &'_ str| { Ok(Some(UserResponse { @@ -443,17 +439,21 @@ mod tests { .expect_expand_domain_information() .returning(|_, _, _| Ok(())); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) + let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -477,12 +477,6 @@ mod tests { #[tokio::test] async fn test_get_expired() { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let identity_mock = MockIdentityProvider::default(); - let resource_mock = MockResourceProvider::default(); let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() @@ -498,17 +492,19 @@ mod tests { .withf(|token: &'_ str, _, _| token == "bar") .returning(|_, _, _| Err(TokenProviderError::Expired)); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) - .resource(resource_mock) + let provider = Provider::mocked_builder() .token(token_mock) .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -549,7 +545,6 @@ mod tests { #[tokio::test] async fn test_post() { - let db = DatabaseConnection::Disconnected; let config = Config::default(); let mut assignment_mock = MockAssignmentProvider::default(); let mut catalog_mock = MockCatalogProvider::default(); @@ -624,7 +619,7 @@ mod tests { .expect_get_catalog() .returning(|_, _| Ok(Vec::new())); - let provider = ProviderBuilder::default() + let provider = Provider::mocked_builder() .config(config.clone()) .assignment(assignment_mock) .catalog(catalog_mock) @@ -634,7 +629,8 @@ mod tests { .build() .unwrap(); - let state = Arc::new(Service::new(config, db, provider).unwrap()); + let state = + Arc::new(Service::new(config, DatabaseConnection::Disconnected, provider).unwrap()); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) diff --git a/src/api/v3/federation/mod.rs b/src/api/v3/federation/mod.rs new file mode 100644 index 00000000..0fd81df9 --- /dev/null +++ b/src/api/v3/federation/mod.rs @@ -0,0 +1,569 @@ +// 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, debug_handler, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::federation::FederationApi; +use crate::keystone::ServiceState; +use types::*; + +mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list, create)) + .routes(routes!(show, update, remove)) +} + +/// List identity providers +#[utoipa::path( + get, + path = "/", + params(IdentityProviderListParameters), + description = "List Identity Providers", + responses( + (status = OK, description = "List of identity providers", body = IdentityProviderList), + (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + tag="identity_providers" +)] +#[tracing::instrument( + name = "api::identity_provider_list", + level = "debug", + skip(state, _user_auth) +)] +async fn list( + Auth(_user_auth): Auth, + Query(query): Query, + State(state): State, +) -> Result { + let providers: Vec = state + .provider + .get_federation_provider() + .list_identity_providers(&state.db, &query.try_into()?) + .await + .map_err(KeystoneApiError::federation)? + .into_iter() + .map(Into::into) + .collect(); + Ok(IdentityProviderList { providers }) +} + +/// Get single identity provider +#[utoipa::path( + get, + path = "/{idp_id}", + description = "Get IDP by ID", + params(), + responses( + (status = OK, description = "IDP object", body = IdentityProviderResponse), + (status = 404, description = "IDP not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="identity_providers" +)] +#[tracing::instrument(name = "api::identity_provider_get", level = "debug", skip(state))] +async fn show( + Auth(user_auth): Auth, + Path(idp_id): Path, + State(state): State, +) -> Result { + state + .provider + .get_federation_provider() + .get_identity_provider(&state.db, &idp_id) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "identity provider".into(), + identifier: idp_id, + }) + })? +} + +/// Create identity provider +#[utoipa::path( + post, + path = "/", + description = "Create new identity provider", + responses( + (status = CREATED, description = "identity provider object", body = IdentityProviderResponse), + ), + tag="identity_providers" +)] +#[tracing::instrument(name = "api::identity_provider_create", level = "debug", skip(state))] +#[debug_handler] +async fn create( + Auth(user_auth): Auth, + State(state): State, + Json(req): Json, +) -> Result { + let res = state + .provider + .get_federation_provider() + .create_identity_provider(&state.db, req.into()) + .await + .map_err(KeystoneApiError::federation)?; + Ok((StatusCode::CREATED, res).into_response()) +} + +/// Update single identity provider +#[utoipa::path( + put, + path = "/{idp_id}", + description = "Update Identity Provider", + params(), + responses( + (status = OK, description = "IDP object", body = IdentityProviderResponse), + (status = 404, description = "IDP not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="identity_providers" +)] +#[tracing::instrument(name = "api::identity_provider_update", level = "debug", skip(state))] +async fn update( + Auth(user_auth): Auth, + Path(idp_id): Path, + State(state): State, + Json(req): Json, +) -> Result { + let res = state + .provider + .get_federation_provider() + .update_identity_provider(&state.db, &idp_id, req.into()) + .await + .map_err(KeystoneApiError::federation)?; + Ok(res.into_response()) +} + +/// Delete Identity provider +#[utoipa::path( + delete, + path = "/{idp_id}", + description = "Delete identity provider by ID", + params(), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "identity provider not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="identity_providers" +)] +#[tracing::instrument(name = "api::identity_provider_delete", level = "debug", skip(state))] +async fn remove( + Auth(user_auth): Auth, + Path(id): Path, + State(state): State, +) -> Result { + state + .provider + .get_federation_provider() + .delete_identity_provider(&state.db, &id) + .await + .map_err(KeystoneApiError::federation)?; + Ok((StatusCode::NO_CONTENT).into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::*; + use crate::config::Config; + use crate::federation::{ + MockFederationProvider, error::FederationProviderError, types as provider_types, + }; + use crate::keystone::{Service, ServiceState}; + use crate::provider::Provider; + use crate::token::{MockTokenProvider, Token, UnscopedToken}; + + fn get_mocked_state(federation_mock: MockFederationProvider) -> ServiceState { + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_validate_token().returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .federation(federation_mock) + .token(token_mock) + .build() + .unwrap(); + + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ) + } + + #[tokio::test] + async fn test_list() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_list_identity_providers() + .withf( + |_: &DatabaseConnection, _: &provider_types::IdentityProviderListParameters| true, + ) + .returning(|_, _| { + Ok(vec![provider_types::IdentityProvider { + id: "id".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }]) + }); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: IdentityProviderList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![IdentityProvider { + id: "id".into(), + name: "name".into(), + domain_id: Some("did".into()), + oidc_discovery_url: None, + oidc_client_id: None, + oidc_response_mode: None, + oidc_response_types: None, + jwt_validation_pubkeys: None, + bound_issuer: None, + provider_config: None + }], + res.providers + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_list_identity_providers() + .withf( + |_: &DatabaseConnection, qp: &provider_types::IdentityProviderListParameters| { + provider_types::IdentityProviderListParameters { + name: Some("name".into()), + domain_id: Some("did".into()), + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![provider_types::IdentityProvider { + id: "id".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }]) + }); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?name=name&domain_id=did") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: IdentityProviderList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_get() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_get_identity_provider() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Ok(None)); + + federation_mock + .expect_get_identity_provider() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(provider_types::IdentityProvider { + id: "bar".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + })) + }); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: IdentityProviderResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + IdentityProvider { + id: "bar".into(), + name: "name".into(), + domain_id: Some("did".into()), + oidc_discovery_url: None, + oidc_client_id: None, + oidc_response_mode: None, + oidc_response_types: None, + jwt_validation_pubkeys: None, + bound_issuer: None, + provider_config: None + }, + res.identity_provider, + ); + } + + #[tokio::test] + async fn test_create() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_create_identity_provider() + .withf( + |_: &DatabaseConnection, req: &provider_types::IdentityProvider| req.name == "name", + ) + .returning(|_, _| { + Ok(provider_types::IdentityProvider { + id: "bar".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }) + }); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = IdentityProviderCreateRequest { + identity_provider: IdentityProviderCreate { + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .uri("/") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: IdentityProviderResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.identity_provider.name, req.identity_provider.name); + assert_eq!( + res.identity_provider.domain_id, + req.identity_provider.domain_id + ); + } + + #[tokio::test] + async fn test_update() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_update_identity_provider() + .withf( + |_: &DatabaseConnection, + id: &'_ str, + req: &provider_types::IdentityProviderUpdate| { + id == "1" && req.name == Some("name".to_string()) + }, + ) + .returning(|_, _, _| { + Ok(provider_types::IdentityProvider { + id: "bar".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }) + }); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = IdentityProviderUpdateRequest { + identity_provider: IdentityProviderUpdate { + name: Some("name".into()), + oidc_client_id: Some(None), + ..Default::default() + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .header(header::CONTENT_TYPE, "application/json") + .uri("/1") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: IdentityProviderResponse = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_delete() { + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_delete_identity_provider() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| { + Err(FederationProviderError::IdentityProviderNotFound( + "foo".into(), + )) + }); + + federation_mock + .expect_delete_identity_provider() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| Ok(())); + + let state = get_mocked_state(federation_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/src/api/v3/federation/types.rs b/src/api/v3/federation/types.rs new file mode 100644 index 00000000..cd052afa --- /dev/null +++ b/src/api/v3/federation/types.rs @@ -0,0 +1,17 @@ +// 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 identity_provider; + +pub use identity_provider::*; diff --git a/src/api/v3/federation/types/identity_provider.rs b/src/api/v3/federation/types/identity_provider.rs new file mode 100644 index 00000000..cf58e449 --- /dev/null +++ b/src/api/v3/federation/types/identity_provider.rs @@ -0,0 +1,266 @@ +// 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, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::{IntoParams, ToSchema}; + +use crate::api::error::KeystoneApiError; +use crate::federation::types; + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct IdentityProvider { + pub id: String, + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub domain_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_discovery_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_client_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_response_mode: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_response_types: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub jwt_validation_pubkeys: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub bound_issuer: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub provider_config: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct IdentityProviderResponse { + /// IDP object + pub identity_provider: IdentityProvider, +} + +#[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderCreate { + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub domain_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_discovery_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_client_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_client_secret: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_response_mode: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub oidc_response_types: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub jwt_validation_pubkeys: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub bound_issuer: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub provider_config: Option, +} + +#[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderUpdate { + pub name: Option, + + #[builder(default)] + pub oidc_discovery_url: Option>, + + #[builder(default)] + pub oidc_client_id: Option>, + + #[builder(default)] + pub oidc_client_secret: Option>, + + #[builder(default)] + pub oidc_response_mode: Option>, + + #[builder(default)] + pub oidc_response_types: Option>>, + + #[builder(default)] + pub jwt_validation_pubkeys: Option>>, + + #[builder(default)] + pub bound_issuer: Option>, + + #[builder(default)] + pub provider_config: Option>, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderCreateRequest { + /// Identity provider object + pub identity_provider: IdentityProviderCreate, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderUpdateRequest { + /// Identity provider object + pub identity_provider: IdentityProviderUpdate, +} + +impl From for IdentityProvider { + fn from(value: types::IdentityProvider) -> Self { + Self { + id: value.id, + name: value.name, + domain_id: value.domain_id, + oidc_discovery_url: value.oidc_discovery_url, + oidc_client_id: value.oidc_client_id, + oidc_response_mode: value.oidc_response_mode, + oidc_response_types: value.oidc_response_types, + jwt_validation_pubkeys: value.jwt_validation_pubkeys, + bound_issuer: value.bound_issuer, + provider_config: value.provider_config, + } + } +} + +impl From for types::IdentityProvider { + fn from(value: IdentityProviderCreateRequest) -> Self { + Self { + id: String::new(), + name: value.identity_provider.name, + domain_id: value.identity_provider.domain_id, + oidc_discovery_url: value.identity_provider.oidc_discovery_url, + oidc_client_id: value.identity_provider.oidc_client_id, + oidc_client_secret: value.identity_provider.oidc_client_secret, + oidc_response_mode: value.identity_provider.oidc_response_mode, + oidc_response_types: value.identity_provider.oidc_response_types, + jwt_validation_pubkeys: value.identity_provider.jwt_validation_pubkeys, + bound_issuer: value.identity_provider.bound_issuer, + provider_config: value.identity_provider.provider_config, + } + } +} + +impl From for types::IdentityProviderUpdate { + fn from(value: IdentityProviderUpdateRequest) -> Self { + Self { + name: value.identity_provider.name, + oidc_discovery_url: value.identity_provider.oidc_discovery_url, + oidc_client_id: value.identity_provider.oidc_client_id, + oidc_client_secret: value.identity_provider.oidc_client_secret, + oidc_response_mode: value.identity_provider.oidc_response_mode, + oidc_response_types: value.identity_provider.oidc_response_types, + jwt_validation_pubkeys: value.identity_provider.jwt_validation_pubkeys, + bound_issuer: value.identity_provider.bound_issuer, + provider_config: value.identity_provider.provider_config, + } + } +} + +impl IntoResponse for types::IdentityProvider { + fn into_response(self) -> Response { + ( + StatusCode::OK, + Json(IdentityProviderResponse { + identity_provider: IdentityProvider::from(self), + }), + ) + .into_response() + } +} + +impl From for KeystoneApiError { + fn from(err: IdentityProviderBuilderError) -> Self { + Self::InternalError(err.to_string()) + } +} + +/// Identity Providers +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct IdentityProviderList { + /// Collection of identity provider objects + pub providers: Vec, +} + +impl IntoResponse for IdentityProviderList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// List identity provider query parameters +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct IdentityProviderListParameters { + /// Filters the response by IDP name. + pub name: Option, + + /// Filters the response by a domain ID. + pub domain_id: Option, +} + +impl From for KeystoneApiError { + fn from(err: types::IdentityProviderListParametersBuilderError) -> Self { + Self::InternalError(err.to_string()) + } +} + +impl TryFrom for types::IdentityProviderListParameters { + type Error = KeystoneApiError; + + fn try_from(value: IdentityProviderListParameters) -> Result { + Ok(Self { + name: value.name, + domain_id: value.domain_id, + }) + } +} diff --git a/src/api/v3/mod.rs b/src/api/v3/mod.rs index 4c81e599..063a2b2e 100644 --- a/src/api/v3/mod.rs +++ b/src/api/v3/mod.rs @@ -23,6 +23,7 @@ use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; pub mod auth; +pub mod federation; pub mod group; pub mod role; pub mod role_assignment; @@ -34,6 +35,10 @@ pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .nest("/auth", auth::openapi_router()) .nest("/groups", group::openapi_router()) + .nest( + "/federation/identity_providers", + federation::openapi_router(), + ) .nest("/role_assignments", role_assignment::openapi_router()) .nest("/roles", role::openapi_router()) .nest("/users", user::openapi_router()) diff --git a/src/api/v3/role/mod.rs b/src/api/v3/role/mod.rs index 30c4b4c8..59ef7997 100644 --- a/src/api/v3/role/mod.rs +++ b/src/api/v3/role/mod.rs @@ -116,41 +116,39 @@ mod tests { MockAssignmentProvider, types::{Role, RoleListParameters}, }; - use crate::catalog::MockCatalogProvider; + use crate::config::Config; - use crate::identity::MockIdentityProvider; + use crate::keystone::{Service, ServiceState}; - use crate::provider::ProviderBuilder; - use crate::resource::MockResourceProvider; + use crate::provider::Provider; + use crate::token::{MockTokenProvider, Token, UnscopedToken}; use crate::tests::api::get_mocked_state_unauthed; fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut token_mock = MockTokenProvider::default(); - let resource_mock = MockResourceProvider::default(); token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() })) }); - let catalog_mock = MockCatalogProvider::default(); - let identity_mock = MockIdentityProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) + let provider = Provider::mocked_builder() .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) - .resource(resource_mock) .token(token_mock) .build() .unwrap(); - Arc::new(Service::new(config, db, provider).unwrap()) + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ) } #[tokio::test] diff --git a/src/api/v3/role_assignment/mod.rs b/src/api/v3/role_assignment/mod.rs index 4fe9d9d5..4acdf2b6 100644 --- a/src/api/v3/role_assignment/mod.rs +++ b/src/api/v3/role_assignment/mod.rs @@ -88,39 +88,37 @@ mod tests { MockAssignmentProvider, types::{Assignment, AssignmentType, RoleAssignmentListParameters}, }; - use crate::catalog::MockCatalogProvider; + use crate::config::Config; - use crate::identity::MockIdentityProvider; + use crate::keystone::{Service, ServiceState}; - use crate::provider::{Provider, ProviderBuilder}; - use crate::resource::MockResourceProvider; + use crate::provider::Provider; + use crate::token::{MockTokenProvider, Token, UnscopedToken}; fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut token_mock = MockTokenProvider::default(); - let resource_mock = MockResourceProvider::default(); token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() })) }); - let identity_mock = MockIdentityProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) + let provider = Provider::mocked_builder() .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) - .resource(resource_mock) .token(token_mock) .build() .unwrap(); - Arc::new(Service::new(config, db, provider).unwrap()) + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ) } #[tokio::test] diff --git a/src/assignment/backends/sql/implied_role.rs b/src/assignment/backends/sql/implied_role.rs index b9a277c6..900bcefb 100644 --- a/src/assignment/backends/sql/implied_role.rs +++ b/src/assignment/backends/sql/implied_role.rs @@ -71,7 +71,6 @@ pub async fn list_rules( mod tests { use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; - use crate::config::Config; use crate::db::entity::implied_role; use super::*; @@ -143,7 +142,6 @@ mod tests { get_implied_role_mock("5".into(), "6".into()), ]]) .into_connection(); - let config = Config::default(); assert_eq!( list_rules(&db, true).await.unwrap(), BTreeMap::from([ diff --git a/src/config.rs b/src/config.rs index 012531af..4afe5e63 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,9 @@ pub struct Config { #[serde(default)] pub catalog: CatalogSection, + #[serde(default)] + pub federation: FederationSection, + /// Fernet tokens #[serde(default)] pub fernet_tokens: FernetTokenSection, @@ -119,6 +122,11 @@ pub struct CatalogSection { pub driver: String, } +#[derive(Debug, Default, Deserialize, Clone)] +pub struct FederationSection { + pub driver: String, +} + #[derive(Debug, Default, Deserialize, Clone)] pub struct IdentitySection { #[serde(default = "default_identity_driver")] @@ -194,6 +202,7 @@ impl Config { .set_default("fernet_tokens.max_active_keys", "3")? .set_default("assignment.driver", "sql")? .set_default("catalog.driver", "sql")? + .set_default("federation.driver", "sql")? .set_default("resource.driver", "sql")? .set_default("token.expiration", "3600")?; if std::path::Path::new(&path).is_file() { diff --git a/src/db/entity.rs b/src/db/entity.rs index 4d2ba301..efbfed0c 100644 --- a/src/db/entity.rs +++ b/src/db/entity.rs @@ -29,6 +29,7 @@ pub mod credential; pub mod endpoint; pub mod endpoint_group; pub mod expiring_user_group_membership; +pub mod federated_identity_provider; pub mod federated_user; pub mod federation_protocol; pub mod group; @@ -133,3 +134,21 @@ impl Default for endpoint::Model { } } } + +impl Default for federated_identity_provider::Model { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + domain_id: None, + oidc_discovery_url: None, + oidc_client_id: None, + oidc_client_secret: None, + oidc_response_mode: None, + oidc_response_types: None, + jwt_validation_pubkeys: None, + bound_issuer: None, + provider_config: None, + } + } +} diff --git a/src/db/entity/federated_identity_provider.rs b/src/db/entity/federated_identity_provider.rs new file mode 100644 index 00000000..c8b4961f --- /dev/null +++ b/src/db/entity/federated_identity_provider.rs @@ -0,0 +1,41 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.7 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "federated_identity_provider")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + pub name: String, + pub domain_id: Option, + pub oidc_discovery_url: Option, + pub oidc_client_id: Option, + pub oidc_client_secret: Option, + pub oidc_response_mode: Option, + pub oidc_response_types: Option, + #[sea_orm(column_type = "Text", nullable)] + pub jwt_validation_pubkeys: Option, + pub bound_issuer: Option, + pub provider_config: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::project::Entity", + from = "(Column::DomainId, Column::DomainId, Column::DomainId, Column::DomainId)", + to = "(super::project::Column::Id, super::project::Column::Id, super::project::Column::Id, super::project::Column::Id)", + on_update = "NoAction", + on_delete = "Cascade" + )] + Project, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Project.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/entity/prelude.rs b/src/db/entity/prelude.rs index 7c75bfbe..8a107844 100644 --- a/src/db/entity/prelude.rs +++ b/src/db/entity/prelude.rs @@ -28,6 +28,7 @@ pub use super::credential::Entity as Credential; pub use super::endpoint::Entity as Endpoint; pub use super::endpoint_group::Entity as EndpointGroup; pub use super::expiring_user_group_membership::Entity as ExpiringUserGroupMembership; +pub use super::federated_identity_provider::Entity as FederatedIdentityProvider; pub use super::federated_user::Entity as FederatedUser; pub use super::federation_protocol::Entity as FederationProtocol; pub use super::group::Entity as Group; diff --git a/src/error.rs b/src/error.rs index efd3155c..6c975ccd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,7 @@ use thiserror::Error; use crate::assignment::error::*; use crate::catalog::error::*; +use crate::federation::error::*; use crate::identity::error::*; use crate::resource::error::*; use crate::token::TokenProviderError; @@ -34,6 +35,12 @@ pub enum KeystoneError { source: CatalogProviderError, }, + #[error(transparent)] + FederationError { + #[from] + source: FederationProviderError, + }, + #[error(transparent)] IdentityError { #[from] diff --git a/src/federation/backends.rs b/src/federation/backends.rs new file mode 100644 index 00000000..a4618644 --- /dev/null +++ b/src/federation/backends.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 error; +pub mod sql; diff --git a/src/federation/backends/error.rs b/src/federation/backends/error.rs new file mode 100644 index 00000000..acdb05ae --- /dev/null +++ b/src/federation/backends/error.rs @@ -0,0 +1,41 @@ +// 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 thiserror::Error; + +use crate::federation::types::*; + +#[derive(Error, Debug)] +pub enum FederationDatabaseError { + #[error("data serialization error")] + Serde { + #[from] + source: serde_json::Error, + }, + + #[error("database error")] + Database { + #[from] + source: sea_orm::DbErr, + }, + + #[error("identity provider {0} not found")] + IdentityProviderNotFound(String), + + #[error(transparent)] + IdentityProviderBuilder { + #[from] + source: IdentityProviderBuilderError, + }, +} diff --git a/src/federation/backends/sql.rs b/src/federation/backends/sql.rs new file mode 100644 index 00000000..d77808a7 --- /dev/null +++ b/src/federation/backends/sql.rs @@ -0,0 +1,93 @@ +// 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 async_trait::async_trait; +use sea_orm::DatabaseConnection; + +use super::super::types::*; +use crate::config::Config; +use crate::federation::FederationProviderError; + +mod identity_provider; + +#[derive(Clone, Debug, Default)] +pub struct SqlBackend { + pub config: Config, +} + +impl SqlBackend {} + +#[async_trait] +impl FederationBackend for SqlBackend { + /// Set config + fn set_config(&mut self, config: Config) { + self.config = config; + } + + /// List IDPs + #[tracing::instrument(level = "debug", skip(self, db))] + async fn list_identity_providers( + &self, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, + ) -> Result, FederationProviderError> { + Ok(identity_provider::list(&self.config, db, params).await?) + } + + /// Get single IDP by ID + #[tracing::instrument(level = "debug", skip(self, db))] + async fn get_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, FederationProviderError> { + Ok(identity_provider::get(&self.config, db, id).await?) + } + + /// Create Identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn create_identity_provider( + &self, + db: &DatabaseConnection, + idp: IdentityProvider, + ) -> Result { + Ok(identity_provider::create(&self.config, db, idp).await?) + } + + /// Update Identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn update_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + idp: IdentityProviderUpdate, + ) -> Result { + Ok(identity_provider::update(&self.config, db, id, idp).await?) + } + + /// Delete identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn delete_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), FederationProviderError> { + identity_provider::delete(&self.config, db, id) + .await + .map_err(FederationProviderError::database) + } +} + +#[cfg(test)] +mod tests {} diff --git a/src/federation/backends/sql/identity_provider.rs b/src/federation/backends/sql/identity_provider.rs new file mode 100644 index 00000000..ce771ce1 --- /dev/null +++ b/src/federation/backends/sql/identity_provider.rs @@ -0,0 +1,445 @@ +// 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 sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use crate::config::Config; +use crate::db::entity::{ + federated_identity_provider as db_federated_identity_provider, + prelude::FederatedIdentityProvider as DbFederatedIdentityProvider, +}; +use crate::federation::backends::error::FederationDatabaseError; +use crate::federation::types::*; + +pub async fn get>( + _conf: &Config, + db: &DatabaseConnection, + id: I, +) -> Result, FederationDatabaseError> { + let select = DbFederatedIdentityProvider::find_by_id(id.as_ref()); + + let entry: Option = select.one(db).await?; + entry.map(TryInto::try_into).transpose() +} + +pub async fn list( + _conf: &Config, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, +) -> Result, FederationDatabaseError> { + let mut select = DbFederatedIdentityProvider::find(); + + if let Some(val) = ¶ms.name { + select = select.filter(db_federated_identity_provider::Column::Name.eq(val)); + } + + if let Some(val) = ¶ms.domain_id { + select = select.filter(db_federated_identity_provider::Column::DomainId.eq(val)); + } + + let db_entities: Vec = select.all(db).await?; + let results: Result, _> = db_entities + .into_iter() + .map(TryInto::::try_into) + .collect(); + + results +} + +pub async fn create( + _conf: &Config, + db: &DatabaseConnection, + idp: IdentityProvider, +) -> Result { + let entry = db_federated_identity_provider::ActiveModel { + id: Set(idp.id.clone()), + domain_id: Set(idp.domain_id.clone()), + name: Set(idp.name.clone()), + oidc_discovery_url: idp + .oidc_discovery_url + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + oidc_client_id: idp.oidc_client_id.clone().map(Set).unwrap_or(NotSet).into(), + oidc_client_secret: idp + .oidc_client_secret + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + oidc_response_mode: idp + .oidc_response_mode + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + oidc_response_types: idp + .oidc_response_types + .clone() + .map(|x| Set(x.join(","))) + .unwrap_or(NotSet) + .into(), + jwt_validation_pubkeys: idp + .jwt_validation_pubkeys + .clone() + .map(|x| Set(x.join(","))) + .unwrap_or(NotSet) + .into(), + bound_issuer: idp.bound_issuer.clone().map(Set).unwrap_or(NotSet).into(), + provider_config: idp + .provider_config + .clone() + .map(|x| Set(Some(x))) + .unwrap_or(NotSet), + }; + + let db_entry: db_federated_identity_provider::Model = entry.insert(db).await?; + + db_entry.try_into() +} + +pub async fn update>( + _conf: &Config, + db: &DatabaseConnection, + id: S, + idp: IdentityProviderUpdate, +) -> Result { + if let Some(current) = DbFederatedIdentityProvider::find_by_id(id.as_ref()) + .one(db) + .await? + { + let mut entry: db_federated_identity_provider::ActiveModel = current.into(); + if let Some(val) = idp.name { + entry.name = Set(val.to_owned()); + } + if let Some(val) = idp.oidc_discovery_url { + entry.oidc_discovery_url = Set(val.to_owned()); + } + if let Some(val) = idp.oidc_client_id { + entry.oidc_client_id = Set(val.to_owned()); + } + if let Some(val) = idp.oidc_client_secret { + entry.oidc_client_secret = Set(val.to_owned()); + } + if let Some(val) = idp.oidc_response_mode { + entry.oidc_response_mode = Set(val.to_owned()); + } + if let Some(val) = idp.oidc_response_types { + entry.oidc_response_types = Set(val.clone().map(|x| x.join(","))); + } + if let Some(val) = idp.jwt_validation_pubkeys { + entry.jwt_validation_pubkeys = Set(val.clone().map(|x| x.join(","))); + } + if let Some(val) = idp.bound_issuer { + entry.bound_issuer = Set(val.to_owned()); + } + if let Some(val) = idp.provider_config { + entry.provider_config = Set(val.to_owned()); + } + + let db_entry: db_federated_identity_provider::Model = entry.update(db).await?; + db_entry.try_into() + } else { + Err(FederationDatabaseError::IdentityProviderNotFound( + id.as_ref().to_string(), + )) + } +} + +pub async fn delete>( + _conf: &Config, + db: &DatabaseConnection, + id: S, +) -> Result<(), FederationDatabaseError> { + let res = DbFederatedIdentityProvider::delete_by_id(id.as_ref()) + .exec(db) + .await?; + if res.rows_affected == 1 { + Ok(()) + } else { + Err(FederationDatabaseError::IdentityProviderNotFound( + id.as_ref().to_string(), + )) + } +} + +impl TryFrom for IdentityProvider { + type Error = FederationDatabaseError; + + fn try_from(value: db_federated_identity_provider::Model) -> Result { + let mut builder = IdentityProviderBuilder::default(); + builder.id(value.id.clone()); + builder.name(value.name.clone()); + if let Some(val) = &value.domain_id { + builder.domain_id(val); + } + if let Some(val) = &value.oidc_discovery_url { + builder.oidc_discovery_url(val); + } + if let Some(val) = &value.oidc_client_id { + builder.oidc_client_id(val); + } + if let Some(val) = &value.oidc_client_secret { + builder.oidc_client_secret(val); + } + if let Some(val) = &value.oidc_response_mode { + builder.oidc_response_mode(val); + } + if let Some(val) = &value.oidc_response_types { + if !val.is_empty() { + builder.oidc_response_types(Vec::from_iter(val.split(",").map(Into::into))); + } + } + if let Some(val) = &value.jwt_validation_pubkeys { + if !val.is_empty() { + builder.jwt_validation_pubkeys(Vec::from_iter(val.split(",").map(Into::into))); + } + } + if let Some(val) = &value.bound_issuer { + builder.bound_issuer(val); + } + if let Some(val) = &value.provider_config { + builder.provider_config(val.clone()); + } + Ok(builder.build()?) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + use serde_json::json; + + use crate::config::Config; + use crate::db::entity::federated_identity_provider; + + use super::*; + + fn get_idp_mock>(id: S) -> federated_identity_provider::Model { + federated_identity_provider::Model { + id: id.as_ref().into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + } + } + + #[tokio::test] + async fn test_get() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")]]) + .into_connection(); + let config = Config::default(); + assert_eq!( + get(&config, &db, "1").await.unwrap().unwrap(), + IdentityProvider { + id: "1".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + } + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, + ["1".into(), 1u64.into()] + ),] + ); + } + + #[tokio::test] + async fn test_list() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")]]) + .append_query_results([vec![get_idp_mock("1")]]) + .into_connection(); + let config = Config::default(); + assert!( + list(&config, &db, &IdentityProviderListParameters::default()) + .await + .is_ok() + ); + assert_eq!( + list( + &config, + &db, + &IdentityProviderListParameters { + name: Some("idp_name".into()), + domain_id: Some("did".into()), + } + ) + .await + .unwrap(), + vec![IdentityProvider { + id: "1".into(), + name: "name".into(), + domain_id: Some("did".into()), + ..Default::default() + }] + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."provider_config" FROM "federated_identity_provider""#, + [] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND "federated_identity_provider"."domain_id" = $2"#, + ["idp_name".into(), "did".into()] + ), + ] + ); + } + + #[tokio::test] + async fn test_create() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")]]) + .into_connection(); + let config = Config::default(); + + let req = IdentityProvider { + id: "1".into(), + name: "idp".into(), + domain_id: Some("foo_domain".into()), + oidc_discovery_url: Some("url".into()), + oidc_client_id: Some("oidccid".into()), + oidc_client_secret: Some("oidccs".into()), + oidc_response_mode: Some("oidcrm".into()), + oidc_response_types: Some(vec!["t1".into(), "t2".into()]), + jwt_validation_pubkeys: Some(vec!["jt1".into(), "jt2".into()]), + bound_issuer: Some("bi".into()), + provider_config: Some(json!({"foo": "bar"})), + }; + + assert_eq!( + create(&config, &db, req).await.unwrap(), + get_idp_mock("1").try_into().unwrap() + ); + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "federated_identity_provider" ("id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "provider_config") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "provider_config""#, + [ + "1".into(), + "idp".into(), + "foo_domain".into(), + "url".into(), + "oidccid".into(), + "oidccs".into(), + "oidcrm".into(), + "t1,t2".into(), + "jt1,jt2".into(), + "bi".into(), + json!({"foo": "bar"}).into() + ] + ),] + ); + } + + #[tokio::test] + async fn test_update() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_idp_mock("1")], vec![get_idp_mock("1")]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + let config = Config::default(); + + let req = IdentityProviderUpdate { + name: Some("idp".into()), + oidc_discovery_url: Some(Some("url".into())), + oidc_client_id: Some(Some("oidccid".into())), + oidc_client_secret: Some(Some("oidccs".into())), + oidc_response_mode: Some(Some("oidcrm".into())), + oidc_response_types: Some(Some(vec!["t1".into(), "t2".into()])), + jwt_validation_pubkeys: Some(Some(vec!["jt1".into(), "jt2".into()])), + bound_issuer: Some(Some("bi".into())), + provider_config: Some(Some(json!({"foo": "bar"}))), + }; + + assert_eq!( + update(&config, &db, "1", req).await.unwrap(), + get_idp_mock("1").try_into().unwrap() + ); + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, + ["1".into(), 1u64.into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"UPDATE "federated_identity_provider" SET "name" = $1, "oidc_discovery_url" = $2, "oidc_client_id" = $3, "oidc_client_secret" = $4, "oidc_response_mode" = $5, "oidc_response_types" = $6, "jwt_validation_pubkeys" = $7, "bound_issuer" = $8, "provider_config" = $9 WHERE "federated_identity_provider"."id" = $10 RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "provider_config""#, + [ + "idp".into(), + "url".into(), + "oidccid".into(), + "oidccs".into(), + "oidcrm".into(), + "t1,t2".into(), + "jt1,jt2".into(), + "bi".into(), + json!({"foo": "bar"}).into(), + "1".into(), + ] + ), + ] + ); + } + + #[tokio::test] + async fn test_delete() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + let config = Config::default(); + + delete(&config, &db, "id").await.unwrap(); + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1"#, + ["id".into()] + ),] + ); + } +} diff --git a/src/federation/error.rs b/src/federation/error.rs new file mode 100644 index 00000000..4221b704 --- /dev/null +++ b/src/federation/error.rs @@ -0,0 +1,53 @@ +// 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 thiserror::Error; + +use crate::federation::backends::error::*; + +#[derive(Error, Debug)] +pub enum FederationProviderError { + /// Unsupported driver + #[error("unsupported driver {0}")] + UnsupportedDriver(String), + + /// Identity provider error + #[error("data serialization error")] + Serde { + #[from] + source: serde_json::Error, + }, + + /// IDP not found + #[error("identity provider {0} not found")] + IdentityProviderNotFound(String), + + /// Identity provider error + #[error(transparent)] + FederationDatabase { + #[from] + source: FederationDatabaseError, + }, +} + +impl FederationProviderError { + pub fn database(source: FederationDatabaseError) -> Self { + match source { + FederationDatabaseError::IdentityProviderNotFound(x) => { + Self::IdentityProviderNotFound(x) + } + _ => Self::FederationDatabase { source }, + } + } +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs new file mode 100644 index 00000000..3bb4d6e7 --- /dev/null +++ b/src/federation/mod.rs @@ -0,0 +1,200 @@ +// 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 async_trait::async_trait; +#[cfg(test)] +use mockall::mock; +use sea_orm::DatabaseConnection; +use uuid::Uuid; + +pub mod backends; +pub mod error; +pub(crate) mod types; + +use crate::config::Config; +use crate::federation::backends::sql::SqlBackend; +use crate::federation::error::FederationProviderError; +use crate::federation::types::*; +use crate::plugin_manager::PluginManager; + +#[derive(Clone, Debug)] +pub struct FederationProvider { + backend_driver: Box, +} + +#[async_trait] +pub trait FederationApi: Send + Sync + Clone { + async fn list_identity_providers( + &self, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, + ) -> Result, FederationProviderError>; + + async fn get_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, FederationProviderError>; + + async fn create_identity_provider( + &self, + db: &DatabaseConnection, + idp: IdentityProvider, + ) -> Result; + + async fn update_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + idp: IdentityProviderUpdate, + ) -> Result; + + async fn delete_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), FederationProviderError>; +} + +#[cfg(test)] +mock! { + pub FederationProvider { + pub fn new(cfg: &Config, plugin_manager: &PluginManager) -> Result; + } + + #[async_trait] + impl FederationApi for FederationProvider { + async fn list_identity_providers( + &self, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, + ) -> Result, FederationProviderError>; + + async fn get_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, FederationProviderError>; + + async fn create_identity_provider( + &self, + db: &DatabaseConnection, + idp: IdentityProvider, + ) -> Result; + + async fn update_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + idp: IdentityProviderUpdate, + ) -> Result; + + async fn delete_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), FederationProviderError>; + } + + impl Clone for FederationProvider { + fn clone(&self) -> Self; + } +} + +impl FederationProvider { + pub fn new( + config: &Config, + plugin_manager: &PluginManager, + ) -> Result { + let mut backend_driver = if let Some(driver) = + plugin_manager.get_federation_backend(config.federation.driver.clone()) + { + driver.clone() + } else { + match config.federation.driver.as_str() { + "sql" => Box::new(SqlBackend::default()), + _ => { + return Err(FederationProviderError::UnsupportedDriver( + config.resource.driver.clone(), + )); + } + } + }; + backend_driver.set_config(config.clone()); + Ok(Self { backend_driver }) + } +} + +#[async_trait] +impl FederationApi for FederationProvider { + /// List IDP + #[tracing::instrument(level = "info", skip(self, db))] + async fn list_identity_providers( + &self, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, + ) -> Result, FederationProviderError> { + self.backend_driver + .list_identity_providers(db, params) + .await + } + + /// Get single IDP by ID + #[tracing::instrument(level = "info", skip(self, db))] + async fn get_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, FederationProviderError> { + self.backend_driver.get_identity_provider(db, id).await + } + + /// Create Identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn create_identity_provider( + &self, + db: &DatabaseConnection, + idp: IdentityProvider, + ) -> Result { + let mut mod_idp = idp; + mod_idp.id = Uuid::new_v4().into(); + + self.backend_driver + .create_identity_provider(db, mod_idp) + .await + } + + /// Update Identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn update_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + idp: IdentityProviderUpdate, + ) -> Result { + self.backend_driver + .update_identity_provider(db, id, idp) + .await + } + + /// Delete identity provider + #[tracing::instrument(level = "debug", skip(self, db))] + async fn delete_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), FederationProviderError> { + self.backend_driver.delete_identity_provider(db, id).await + } +} diff --git a/src/federation/types.rs b/src/federation/types.rs new file mode 100644 index 00000000..808b0424 --- /dev/null +++ b/src/federation/types.rs @@ -0,0 +1,68 @@ +// 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 identity_provider; + +use async_trait::async_trait; +use dyn_clone::DynClone; +use sea_orm::DatabaseConnection; + +use crate::config::Config; +use crate::federation::FederationProviderError; + +pub use identity_provider::*; + +#[async_trait] +pub trait FederationBackend: DynClone + Send + Sync + std::fmt::Debug { + /// Set config + fn set_config(&mut self, config: Config); + + /// List Identity Providers + async fn list_identity_providers( + &self, + db: &DatabaseConnection, + params: &IdentityProviderListParameters, + ) -> Result, FederationProviderError>; + + /// Get single identity provider by ID + async fn get_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, FederationProviderError>; + + /// Create Identity provider + async fn create_identity_provider( + &self, + db: &DatabaseConnection, + idp: IdentityProvider, + ) -> Result; + + /// Update Identity provider + async fn update_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + idp: IdentityProviderUpdate, + ) -> Result; + + /// Delete identity provider + async fn delete_identity_provider<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), FederationProviderError>; +} + +dyn_clone::clone_trait_object!(FederationBackend); diff --git a/src/federation/types/identity_provider.rs b/src/federation/types/identity_provider.rs new file mode 100644 index 00000000..e48ca107 --- /dev/null +++ b/src/federation/types/identity_provider.rs @@ -0,0 +1,94 @@ +// 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 derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[builder(setter(strip_option, into))] +pub struct IdentityProvider { + /// Federation provider ID + pub id: String, + + /// Provider name + pub name: String, + + #[builder(default)] + pub domain_id: Option, + + #[builder(default)] + pub oidc_discovery_url: Option, + + #[builder(default)] + pub oidc_client_id: Option, + + #[builder(default)] + pub oidc_client_secret: Option, + + #[builder(default)] + pub oidc_response_mode: Option, + + #[builder(default)] + pub oidc_response_types: Option>, + + #[builder(default)] + pub jwt_validation_pubkeys: Option>, + + #[builder(default)] + pub bound_issuer: Option, + + #[builder(default)] + pub provider_config: Option, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderUpdate { + /// Provider name + pub name: Option, + + #[builder(default)] + pub oidc_discovery_url: Option>, + + #[builder(default)] + pub oidc_client_id: Option>, + + #[builder(default)] + pub oidc_client_secret: Option>, + + #[builder(default)] + pub oidc_response_mode: Option>, + + #[builder(default)] + pub oidc_response_types: Option>>, + + #[builder(default)] + pub jwt_validation_pubkeys: Option>>, + + #[builder(default)] + pub bound_issuer: Option>, + + #[builder(default)] + pub provider_config: Option>, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(strip_option, into))] +pub struct IdentityProviderListParameters { + /// Filters the response by IDP name. + pub name: Option, + /// Filters the response by a domain_id ID. + pub domain_id: Option, +} diff --git a/src/lib.rs b/src/lib.rs index da64ee85..2c999c1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod catalog; pub mod config; pub mod db; pub mod error; +pub mod federation; pub mod identity; pub mod keystone; pub mod plugin_manager; diff --git a/src/plugin_manager.rs b/src/plugin_manager.rs index 9e70d841..fe3f17e9 100644 --- a/src/plugin_manager.rs +++ b/src/plugin_manager.rs @@ -16,6 +16,7 @@ use std::collections::HashMap; use crate::assignment::types::AssignmentBackend; use crate::catalog::types::CatalogBackend; +use crate::federation::types::FederationBackend; use crate::identity::types::IdentityBackend; use crate::resource::types::ResourceBackend; @@ -27,6 +28,8 @@ pub struct PluginManager { assignment_backends: HashMap>, /// Catalog backend plugins catalog_backends: HashMap>, + /// Federation backend plugins + federation_backends: HashMap>, /// Identity backend plugins identity_backends: HashMap>, /// Resource backend plugins @@ -59,6 +62,15 @@ impl PluginManager { self.catalog_backends.get(name.as_ref()) } + /// Get registered federation backend + #[allow(clippy::borrowed_box)] + pub fn get_federation_backend>( + &self, + name: S, + ) -> Option<&Box> { + self.federation_backends.get(name.as_ref()) + } + /// Get registered identity backend #[allow(clippy::borrowed_box)] pub fn get_identity_backend>( diff --git a/src/provider.rs b/src/provider.rs index 076d81fd..14a05837 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -22,6 +22,9 @@ use crate::catalog::CatalogApi; use crate::catalog::CatalogProvider; use crate::config::Config; use crate::error::KeystoneError; +use crate::federation::FederationApi; +#[double] +use crate::federation::FederationProvider; use crate::identity::IdentityApi; #[double] use crate::identity::IdentityProvider; @@ -46,6 +49,7 @@ pub struct Provider { pub config: Config, assignment: AssignmentProvider, catalog: CatalogProvider, + federation: FederationProvider, identity: IdentityProvider, resource: ResourceProvider, token: TokenProvider, @@ -55,6 +59,7 @@ impl Provider { pub fn new(cfg: Config, plugin_manager: PluginManager) -> Result { let assignment_provider = AssignmentProvider::new(&cfg, &plugin_manager)?; let catalog_provider = CatalogProvider::new(&cfg, &plugin_manager)?; + let federation_provider = FederationProvider::new(&cfg, &plugin_manager)?; let identity_provider = IdentityProvider::new(&cfg, &plugin_manager)?; let resource_provider = ResourceProvider::new(&cfg, &plugin_manager)?; let token_provider = TokenProvider::new(&cfg)?; @@ -63,6 +68,7 @@ impl Provider { config: cfg, assignment: assignment_provider, catalog: catalog_provider, + federation: federation_provider, identity: identity_provider, resource: resource_provider, token: token_provider, @@ -77,6 +83,10 @@ impl Provider { &self.catalog } + pub fn get_federation_provider(&self) -> &impl FederationApi { + &self.federation + } + pub fn get_identity_provider(&self) -> &impl IdentityApi { &self.identity } @@ -89,3 +99,25 @@ impl Provider { &self.token } } + +#[cfg(test)] +impl Provider { + pub fn mocked_builder() -> ProviderBuilder { + let config = Config::default(); + let identity_mock = crate::identity::MockIdentityProvider::default(); + let resource_mock = crate::resource::MockResourceProvider::default(); + let token_mock = crate::token::MockTokenProvider::default(); + let assignment_mock = crate::assignment::MockAssignmentProvider::default(); + let catalog_mock = crate::catalog::MockCatalogProvider::default(); + let federation_mock = crate::federation::MockFederationProvider::default(); + + ProviderBuilder::default() + .config(config.clone()) + .assignment(assignment_mock) + .catalog(catalog_mock) + .identity(identity_mock) + .federation(federation_mock) + .resource(resource_mock) + .token(token_mock) + } +} diff --git a/src/tests/api.rs b/src/tests/api.rs index bf8393c2..45a1dc57 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -15,63 +15,54 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; -use crate::assignment::MockAssignmentProvider; -use crate::catalog::MockCatalogProvider; use crate::config::Config; use crate::identity::MockIdentityProvider; use crate::keystone::{Service, ServiceState}; -use crate::provider::ProviderBuilder; -use crate::resource::MockResourceProvider; +use crate::provider::Provider; use crate::token::{MockTokenProvider, Token, TokenProviderError, UnscopedToken}; pub(crate) fn get_mocked_state_unauthed() -> ServiceState { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let identity_mock = MockIdentityProvider::default(); - let resource_mock = MockResourceProvider::default(); let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() .returning(|_, _, _| Err(TokenProviderError::InvalidToken)); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) - .resource(resource_mock) + let provider = Provider::mocked_builder() .token(token_mock) .build() .unwrap(); - Arc::new(Service::new(config, db, provider).unwrap()) + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ) } pub(crate) fn get_mocked_state(identity_mock: MockIdentityProvider) -> ServiceState { - let db = DatabaseConnection::Disconnected; - let config = Config::default(); let mut token_mock = MockTokenProvider::default(); - let resource_mock = MockResourceProvider::default(); token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() })) }); - let assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); - let provider = ProviderBuilder::default() - .config(config.clone()) - .assignment(assignment_mock) - .catalog(catalog_mock) + let provider = Provider::mocked_builder() .identity(identity_mock) - .resource(resource_mock) .token(token_mock) .build() .unwrap(); - Arc::new(Service::new(config, db, provider).unwrap()) + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + ) + .unwrap(), + ) } diff --git a/src/token/mod.rs b/src/token/mod.rs index e72ac0dc..908b8df3 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -410,25 +410,16 @@ mod tests { MockAssignmentProvider, types::{Assignment, AssignmentType, Role, RoleAssignmentListParameters}, }; - use crate::catalog::MockCatalogProvider; + use crate::config::Config; - use crate::identity::MockIdentityProvider; - use crate::provider::ProviderBuilder; - use crate::resource::MockResourceProvider; - use crate::token::{ - DomainScopeToken, MockTokenProvider, ProjectScopeToken, Token, UnscopedToken, - }; + + use crate::token::{DomainScopeToken, ProjectScopeToken, Token, UnscopedToken}; #[tokio::test] async fn test_populate_role_assignments() { - let config = Config::default(); - let token_provider = TokenProvider::new(&config).unwrap(); + let token_provider = TokenProvider::new(&Config::default()).unwrap(); let db = DatabaseConnection::Disconnected; - let identity_mock = MockIdentityProvider::default(); - let resource_mock = MockResourceProvider::default(); - let token_mock = MockTokenProvider::default(); let mut assignment_mock = MockAssignmentProvider::default(); - let catalog_mock = MockCatalogProvider::default(); assignment_mock .expect_list_role_assignments() .withf(|_, _, q: &RoleAssignmentListParameters| { @@ -459,13 +450,8 @@ mod tests { inherited: false, }]) }); - let provider = ProviderBuilder::default() - .config(config.clone()) + let provider = Provider::mocked_builder() .assignment(assignment_mock) - .catalog(catalog_mock) - .identity(identity_mock) - .resource(resource_mock) - .token(token_mock) .build() .unwrap();