diff --git a/CHANGELOG.md b/CHANGELOG.md index 77620510d..a3b2f6318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- Initial support for local predefined Admin users + ## [0.151.0] - 2024-01-09 ### Added - New GraphQL APIs to manually schedule and cancel dataset flows diff --git a/resources/schema.gql b/resources/schema.gql index 328625753..bc8a41a99 100644 --- a/resources/schema.gql +++ b/resources/schema.gql @@ -9,7 +9,7 @@ directive @oneOf on INPUT_OBJECT type Account { """ - Unique and stable identitfier of this account + Unique and stable identifier of this account """ id: AccountID! """ @@ -28,6 +28,10 @@ type Account { Avatar URL """ avatarUrl: String + """ + Indicates the administrator status + """ + isAdmin: Boolean! } scalar AccountDisplayName @@ -67,6 +71,10 @@ type AddPushSource { merge: MergeStrategy! } +type Admin { + selfTest: String! +} + type AttachmentEmbedded { path: String! content: String! @@ -529,7 +537,7 @@ type EngineDesc { name: String! """ Language and dialect this engine is using for queries - Indended for configuring code highlighting and completions. + Indented for configuring code highlighting and completions. """ dialect: QueryDialect! """ @@ -754,7 +762,7 @@ type Mutation { """ Dataset-related functionality group. - Datasets are historical streams of events recorded under a cetrain + Datasets are historical streams of events recorded under a certain schema. """ datasets: DatasetsMut! @@ -819,7 +827,7 @@ type Query { """ Dataset-related functionality group. - Datasets are historical streams of events recorded under a cetrain + Datasets are historical streams of events recorded under a certain schema. """ datasets: Datasets! @@ -839,13 +847,17 @@ type Query { """ tasks: Tasks! """ - Search-related functionality group. + Search-related functionality group """ search: Search! """ Querying and data manipulations """ data: DataQueries! + """ + Admin-related functionality group + """ + admin: Admin! } enum QueryDialect { @@ -1060,7 +1072,7 @@ type SqlQueryStep { type Task { """ - Unique and stable identitfier of this task + Unique and stable identifier of this task """ taskId: TaskID! """ diff --git a/src/adapter/auth-oso/src/kamu_auth_oso.rs b/src/adapter/auth-oso/src/kamu_auth_oso.rs index 9e893064c..518599c63 100644 --- a/src/adapter/auth-oso/src/kamu_auth_oso.rs +++ b/src/adapter/auth-oso/src/kamu_auth_oso.rs @@ -27,7 +27,7 @@ impl KamuAuthOso { let oso = match KamuAuthOso::load_oso() { Ok(oso) => oso, Err(e) => { - panic!("Failed to initialize OSO: {:?}", e); + panic!("Failed to initialize OSO: {e:?}"); } }; @@ -40,31 +40,7 @@ impl KamuAuthOso { oso.register_class(DatasetResource::get_polar_class())?; oso.register_class(UserActor::get_polar_class())?; - oso.load_str( - r#" - actor UserActor {} - - resource DatasetResource { - permissions = ["read", "write"]; - } - - has_permission(actor: UserActor, "read", dataset: DatasetResource) if - dataset.allows_public_read or - dataset.created_by == actor.name or ( - actor_name = actor.name and - dataset.authorized_users.(actor_name) in ["Reader", "Editor"] - ); - - has_permission(actor: UserActor, "write", dataset: DatasetResource) if - dataset.created_by == actor.name or ( - actor_name = actor.name and - dataset.authorized_users.(actor_name) == "Editor" - ); - - allow(actor: UserActor, action: String, dataset: DatasetResource) if - has_permission(actor, action, dataset); - "#, - )?; + oso.load_str(include_str!("schema.polar"))?; Ok(oso) } diff --git a/src/adapter/auth-oso/src/oso_dataset_authorizer.rs b/src/adapter/auth-oso/src/oso_dataset_authorizer.rs index 26d6d0600..57be0965b 100644 --- a/src/adapter/auth-oso/src/oso_dataset_authorizer.rs +++ b/src/adapter/auth-oso/src/oso_dataset_authorizer.rs @@ -45,8 +45,10 @@ impl OsoDatasetAuthorizer { fn actor(&self) -> UserActor { match self.current_account_subject.as_ref() { - CurrentAccountSubject::Anonymous(_) => UserActor::new("", true), - CurrentAccountSubject::Logged(l) => UserActor::new(l.account_name.as_str(), false), + CurrentAccountSubject::Anonymous(_) => UserActor::new("", true, false), + CurrentAccountSubject::Logged(l) => { + UserActor::new(l.account_name.as_str(), false, l.is_admin) + } } } diff --git a/src/adapter/auth-oso/src/schema.polar b/src/adapter/auth-oso/src/schema.polar new file mode 100644 index 000000000..e9407487a --- /dev/null +++ b/src/adapter/auth-oso/src/schema.polar @@ -0,0 +1,22 @@ +actor UserActor {} + +resource DatasetResource { + permissions = ["read", "write"]; +} + +has_permission(actor: UserActor, "read", dataset: DatasetResource) if + dataset.allows_public_read or + dataset.created_by == actor.name or ( + actor_name = actor.name and + dataset.authorized_users.(actor_name) in ["Reader", "Editor"] + ); + +has_permission(actor: UserActor, "write", dataset: DatasetResource) if + dataset.created_by == actor.name or ( + actor_name = actor.name and + dataset.authorized_users.(actor_name) == "Editor" + ); + +allow(actor: UserActor, action: String, dataset: DatasetResource) if + actor.is_admin or + has_permission(actor, action, dataset); diff --git a/src/adapter/auth-oso/src/user_actor.rs b/src/adapter/auth-oso/src/user_actor.rs index aa2ca9773..3af14c1bf 100644 --- a/src/adapter/auth-oso/src/user_actor.rs +++ b/src/adapter/auth-oso/src/user_actor.rs @@ -17,15 +17,18 @@ pub struct UserActor { pub name: String, #[polar(attribute)] pub anonymous: bool, + #[polar(attribute)] + pub is_admin: bool, } /////////////////////////////////////////////////////////////////////////////// impl UserActor { - pub fn new(name: &str, anonymous: bool) -> Self { + pub fn new(name: &str, anonymous: bool, is_admin: bool) -> Self { Self { name: name.to_string(), anonymous, + is_admin, } } } @@ -36,8 +39,8 @@ impl std::fmt::Display for UserActor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "User(name='{}', anonymous={})", - &self.name, self.anonymous + "User(name='{}', anonymous={}, is_admin={})", + &self.name, self.anonymous, self.is_admin ) } } diff --git a/src/adapter/auth-oso/tests/tests/test_oso.rs b/src/adapter/auth-oso/tests/tests/test_oso.rs index 104427824..8c099eae4 100644 --- a/src/adapter/auth-oso/tests/tests/test_oso.rs +++ b/src/adapter/auth-oso/tests/tests/test_oso.rs @@ -30,7 +30,8 @@ macro_rules! assert_forbidden { #[test_log::test(tokio::test)] async fn test_owner_can_read_and_write() { - let user_actor = UserActor::new("foo", false); + let is_admin = false; + let user_actor = UserActor::new("foo", false, is_admin); let dataset_resource = DatasetResource::new("foo", false); let oso = KamuAuthOso::new().oso; @@ -54,7 +55,8 @@ async fn test_owner_can_read_and_write() { #[test_log::test(tokio::test)] async fn test_unrelated_can_read_public() { - let user_actor = UserActor::new("foo", false); + let is_admin = false; + let user_actor = UserActor::new("foo", false, is_admin); let dataset_resource = DatasetResource::new("bar", true); let oso = KamuAuthOso::new().oso; @@ -78,7 +80,8 @@ async fn test_unrelated_can_read_public() { #[test_log::test(tokio::test)] async fn test_unrelated_cannot_read_private() { - let user_actor = UserActor::new("foo", false); + let is_admin = false; + let user_actor = UserActor::new("foo", false, is_admin); let dataset_resource = DatasetResource::new("bar", false); let oso = KamuAuthOso::new().oso; @@ -102,7 +105,8 @@ async fn test_unrelated_cannot_read_private() { #[test_log::test(tokio::test)] async fn test_having_explicit_read_permission_in_private_dataset() { - let user_actor = UserActor::new("foo", false); + let is_admin = false; + let user_actor = UserActor::new("foo", false, is_admin); let mut dataset_resource = DatasetResource::new("bar", false); dataset_resource.authorize_reader("foo"); @@ -127,7 +131,8 @@ async fn test_having_explicit_read_permission_in_private_dataset() { #[test_log::test(tokio::test)] async fn test_having_explicit_write_permission_in_private_dataset() { - let user_actor = UserActor::new("foo", false); + let is_admin = false; + let user_actor = UserActor::new("foo", false, is_admin); let mut dataset_resource = DatasetResource::new("bar", false); dataset_resource.authorize_editor("foo"); @@ -149,3 +154,28 @@ async fn test_having_explicit_write_permission_in_private_dataset() { } ///////////////////////////////////////////////////////////////////////////////////////// + +#[test_log::test(tokio::test)] +async fn test_admin_can_read_and_write_another_private_dataset() { + let is_admin = true; + let user_actor = UserActor::new("foo", false, is_admin); + let dataset_resource = DatasetResource::new("bar", false); + + let oso = KamuAuthOso::new().oso; + + let write_result = oso.is_allowed( + user_actor.clone(), + format!("{}", DatasetAction::Write), + dataset_resource.clone(), + ); + let read_result = oso.is_allowed( + user_actor.clone(), + format!("{}", DatasetAction::Read), + dataset_resource.clone(), + ); + + assert_allowed!(write_result); + assert_allowed!(read_result); +} + +///////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/adapter/auth-oso/tests/tests/test_oso_dataset_authorizer.rs b/src/adapter/auth-oso/tests/tests/test_oso_dataset_authorizer.rs index 8fdc3502b..fa935f94b 100644 --- a/src/adapter/auth-oso/tests/tests/test_oso_dataset_authorizer.rs +++ b/src/adapter/auth-oso/tests/tests/test_oso_dataset_authorizer.rs @@ -106,9 +106,10 @@ impl DatasetAuthorizerHarness { let catalog = dill::CatalogBuilder::new() .add::() - .add_value(CurrentAccountSubject::logged(AccountName::new_unchecked( - current_account_name, - ))) + .add_value(CurrentAccountSubject::logged( + AccountName::new_unchecked(current_account_name), + false, + )) .add::() .add::() .add::() diff --git a/src/adapter/graphql/src/guards.rs b/src/adapter/graphql/src/guards.rs index 7619d1c20..add40de8f 100644 --- a/src/adapter/graphql/src/guards.rs +++ b/src/adapter/graphql/src/guards.rs @@ -45,3 +45,27 @@ impl Guard for LoggedInGuard { } //////////////////////////////////////////////////////////////////////////////////////// + +pub const STAFF_ONLY_MESSAGE: &str = "Access restricted to administrators only"; + +pub struct AdminGuard {} + +impl AdminGuard { + pub fn new() -> Self { + Self {} + } +} + +#[async_trait::async_trait] +impl Guard for AdminGuard { + async fn check(&self, ctx: &Context<'_>) -> Result<()> { + let current_account_subject = from_catalog::(ctx).unwrap(); + + match current_account_subject.as_ref() { + CurrentAccountSubject::Logged(a) if a.is_admin => Ok(()), + _ => Err(async_graphql::Error::new(STAFF_ONLY_MESSAGE)), + } + } +} + +//////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/adapter/graphql/src/queries/accounts/account.rs b/src/adapter/graphql/src/queries/accounts/account.rs index e0fccc5e1..cba016a0c 100644 --- a/src/adapter/graphql/src/queries/accounts/account.rs +++ b/src/adapter/graphql/src/queries/accounts/account.rs @@ -98,12 +98,23 @@ impl Account { }) } + #[graphql(skip)] + #[inline] + async fn get_full_account_info( + &self, + ctx: &Context<'_>, + ) -> Result<&kamu_core::auth::AccountInfo> { + self.full_account_info + .get_or_try_init(|| self.resolve_full_account_info(ctx)) + .await + } + #[graphql(skip)] pub(crate) fn account_name_internal(&self) -> &AccountName { &self.account_name } - /// Unique and stable identitfier of this account + /// Unique and stable identifier of this account async fn id(&self) -> &AccountID { &self.account_id } @@ -115,10 +126,7 @@ impl Account { /// Account name to display async fn display_name(&self, ctx: &Context<'_>) -> Result { - let full_account_info = self - .full_account_info - .get_or_try_init(|| self.resolve_full_account_info(ctx)) - .await?; + let full_account_info = self.get_full_account_info(ctx).await?; Ok(AccountDisplayName::from( full_account_info.display_name.clone(), @@ -127,10 +135,7 @@ impl Account { /// Account type async fn account_type(&self, ctx: &Context<'_>) -> Result { - let full_account_info = self - .full_account_info - .get_or_try_init(|| self.resolve_full_account_info(ctx)) - .await?; + let full_account_info = self.get_full_account_info(ctx).await?; Ok(match full_account_info.account_type { kamu_core::auth::AccountType::User => AccountType::User, @@ -140,12 +145,17 @@ impl Account { /// Avatar URL async fn avatar_url(&self, ctx: &Context<'_>) -> Result<&Option> { - let full_account_info = self - .full_account_info - .get_or_try_init(|| self.resolve_full_account_info(ctx)) - .await?; + let full_account_info = self.get_full_account_info(ctx).await?; + Ok(&full_account_info.avatar_url) } + + /// Indicates the administrator status + async fn is_admin(&self, ctx: &Context<'_>) -> Result { + let full_account_info = self.get_full_account_info(ctx).await?; + + Ok(full_account_info.is_admin) + } } /////////////////////////////////////////////////////////////////////////////// diff --git a/src/adapter/graphql/src/queries/admin/admin.rs b/src/adapter/graphql/src/queries/admin/admin.rs new file mode 100644 index 000000000..efa90c9b0 --- /dev/null +++ b/src/adapter/graphql/src/queries/admin/admin.rs @@ -0,0 +1,21 @@ +// Copyright Kamu Data, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +use crate::prelude::*; +use crate::AdminGuard; + +pub struct Admin; + +#[Object] +impl Admin { + #[graphql(guard = "AdminGuard::new()")] + async fn self_test(&self, _ctx: &Context<'_>) -> Result { + Ok("OK".to_string()) + } +} diff --git a/src/adapter/graphql/src/queries/admin/mod.rs b/src/adapter/graphql/src/queries/admin/mod.rs new file mode 100644 index 000000000..055b46f17 --- /dev/null +++ b/src/adapter/graphql/src/queries/admin/mod.rs @@ -0,0 +1,12 @@ +// Copyright Kamu Data, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +mod admin; + +pub(crate) use admin::*; diff --git a/src/adapter/graphql/src/queries/mod.rs b/src/adapter/graphql/src/queries/mod.rs index 5f327cc5d..2bdd360ca 100644 --- a/src/adapter/graphql/src/queries/mod.rs +++ b/src/adapter/graphql/src/queries/mod.rs @@ -8,6 +8,7 @@ // by the Apache License, Version 2.0. mod accounts; +mod admin; mod auth; mod data; mod datasets; @@ -15,6 +16,7 @@ mod search; mod tasks; pub(crate) use accounts::*; +pub(crate) use admin::*; pub(crate) use auth::*; pub(crate) use data::*; pub(crate) use datasets::*; diff --git a/src/adapter/graphql/src/queries/tasks/task.rs b/src/adapter/graphql/src/queries/tasks/task.rs index 4ea4ac6f0..f4d1713f8 100644 --- a/src/adapter/graphql/src/queries/tasks/task.rs +++ b/src/adapter/graphql/src/queries/tasks/task.rs @@ -26,7 +26,7 @@ impl Task { Self { state } } - /// Unique and stable identitfier of this task + /// Unique and stable identifier of this task async fn task_id(&self) -> TaskID { self.state.task_id.into() } diff --git a/src/adapter/graphql/src/root.rs b/src/adapter/graphql/src/root.rs index c6958c304..d45f140e7 100644 --- a/src/adapter/graphql/src/root.rs +++ b/src/adapter/graphql/src/root.rs @@ -32,7 +32,7 @@ impl Query { /// Dataset-related functionality group. /// - /// Datasets are historical streams of events recorded under a cetrain + /// Datasets are historical streams of events recorded under a certain /// schema. async fn datasets(&self) -> Datasets { Datasets @@ -55,7 +55,7 @@ impl Query { Tasks } - /// Search-related functionality group. + /// Search-related functionality group async fn search(&self) -> Search { Search } @@ -64,6 +64,11 @@ impl Query { async fn data(&self) -> DataQueries { DataQueries } + + /// Admin-related functionality group + async fn admin(&self) -> Admin { + Admin + } } //////////////////////////////////////////////////////////////////////////////////////// @@ -81,7 +86,7 @@ impl Mutation { /// Dataset-related functionality group. /// - /// Datasets are historical streams of events recorded under a cetrain + /// Datasets are historical streams of events recorded under a certain /// schema. async fn datasets(&self) -> DatasetsMut { DatasetsMut diff --git a/src/adapter/graphql/src/scalars/engine_desc.rs b/src/adapter/graphql/src/scalars/engine_desc.rs index dbb06e338..dd85e3fa0 100644 --- a/src/adapter/graphql/src/scalars/engine_desc.rs +++ b/src/adapter/graphql/src/scalars/engine_desc.rs @@ -20,7 +20,7 @@ pub struct EngineDesc { /// Intended for use in UI for quick engine identification and selection. pub name: String, /// Language and dialect this engine is using for queries - /// Indended for configuring code highlighting and completions. + /// Indented for configuring code highlighting and completions. pub dialect: QueryDialect, /// OCI image repository and a tag of the latest engine image, e.g. /// "ghcr.io/kamu-data/engine-datafusion:0.1.2" diff --git a/src/adapter/http/src/http_server_dataset_router.rs b/src/adapter/http/src/http_server_dataset_router.rs index fd9806d8c..d7b1bbfa5 100644 --- a/src/adapter/http/src/http_server_dataset_router.rs +++ b/src/adapter/http/src/http_server_dataset_router.rs @@ -85,24 +85,18 @@ pub async fn platform_token_validate_handler( let current_account_subject = catalog.get_one::().unwrap(); match current_account_subject.as_ref() { - CurrentAccountSubject::Logged(_) => { - return axum::response::Response::builder() - .status(http::StatusCode::OK) - .body(Default::default()) - .unwrap() - } - CurrentAccountSubject::Anonymous(reason) => { - return axum::response::Response::builder() - .status(match reason { - AnonymousAccountReason::AuthenticationExpired => http::StatusCode::UNAUTHORIZED, - AnonymousAccountReason::AuthenticationInvalid => http::StatusCode::BAD_REQUEST, - AnonymousAccountReason::NoAuthenticationProvided => { - http::StatusCode::BAD_REQUEST - } - }) - .body(Default::default()) - .unwrap(); - } + CurrentAccountSubject::Logged(_) => axum::response::Response::builder() + .status(http::StatusCode::OK) + .body(Default::default()) + .unwrap(), + CurrentAccountSubject::Anonymous(reason) => axum::response::Response::builder() + .status(match reason { + AnonymousAccountReason::AuthenticationExpired => http::StatusCode::UNAUTHORIZED, + AnonymousAccountReason::AuthenticationInvalid => http::StatusCode::BAD_REQUEST, + AnonymousAccountReason::NoAuthenticationProvided => http::StatusCode::BAD_REQUEST, + }) + .body(Default::default()) + .unwrap(), } } diff --git a/src/adapter/http/src/middleware/authentication_layer.rs b/src/adapter/http/src/middleware/authentication_layer.rs index e57452369..d5215adb3 100644 --- a/src/adapter/http/src/middleware/authentication_layer.rs +++ b/src/adapter/http/src/middleware/authentication_layer.rs @@ -63,20 +63,23 @@ impl AuthenticationMiddleware { .account_info_by_token(access_token.token) .await { - Ok(account_info) => Ok(CurrentAccountSubject::logged(account_info.account_name)), + Ok(account_info) => Ok(CurrentAccountSubject::logged( + account_info.account_name, + account_info.is_admin, + )), Err(auth::GetAccountInfoError::AccessToken(e)) => match e { auth::AccessTokenError::Expired => Ok(CurrentAccountSubject::anonymous( AnonymousAccountReason::AuthenticationExpired, )), auth::AccessTokenError::Invalid(_) => { - tracing::warn!("Ingoring invalid auth token"); + tracing::warn!("Ignoring invalid auth token"); Ok(CurrentAccountSubject::anonymous( AnonymousAccountReason::AuthenticationInvalid, )) } }, Err(auth::GetAccountInfoError::Internal(_)) => { - return Err(internal_server_error_response()); + Err(internal_server_error_response()) } } } else { diff --git a/src/adapter/http/tests/harness/client_side_harness.rs b/src/adapter/http/tests/harness/client_side_harness.rs index dc64225d9..5f46d71d3 100644 --- a/src/adapter/http/tests/harness/client_side_harness.rs +++ b/src/adapter/http/tests/harness/client_side_harness.rs @@ -57,9 +57,10 @@ impl ClientSideHarness { b.add::(); - b.add_value(CurrentAccountSubject::logged(AccountName::new_unchecked( - CLIENT_ACCOUNT_NAME, - ))); + b.add_value(CurrentAccountSubject::logged( + AccountName::new_unchecked(CLIENT_ACCOUNT_NAME), + false, + )); b.add::(); diff --git a/src/adapter/http/tests/harness/server_side_harness.rs b/src/adapter/http/tests/harness/server_side_harness.rs index a7e4ea446..dc27bf430 100644 --- a/src/adapter/http/tests/harness/server_side_harness.rs +++ b/src/adapter/http/tests/harness/server_side_harness.rs @@ -68,6 +68,7 @@ pub(crate) fn server_authentication_mock() -> MockAuthenticationService { account_type: AccountType::User, display_name: SERVER_ACCOUNT_NAME.to_string(), avatar_url: Some(DEFAULT_AVATAR_URL.to_string()), + is_admin: false, }, ) } @@ -75,10 +76,13 @@ pub(crate) fn server_authentication_mock() -> MockAuthenticationService { ///////////////////////////////////////////////////////////////////////////////////////// pub(crate) fn create_cli_user_catalog(base_catalog: &dill::Catalog) -> dill::Catalog { + let is_admin = false; + dill::CatalogBuilder::new_chained(base_catalog) - .add_value(CurrentAccountSubject::logged(AccountName::new_unchecked( - SERVER_ACCOUNT_NAME, - ))) + .add_value(CurrentAccountSubject::logged( + AccountName::new_unchecked(SERVER_ACCOUNT_NAME), + is_admin, + )) .add::() .build() } diff --git a/src/adapter/oauth/src/oauth_github.rs b/src/adapter/oauth/src/oauth_github.rs index ee8ec7877..2cf348aee 100644 --- a/src/adapter/oauth/src/oauth_github.rs +++ b/src/adapter/oauth/src/oauth_github.rs @@ -343,7 +343,7 @@ struct GithubProviderCredentials { pub access_token: String, } -impl From for kamu_core::auth::AccountInfo { +impl From for AccountInfo { fn from(value: GithubAccountInfo) -> Self { Self { account_id: FAKE_ACCOUNT_ID.to_string(), @@ -351,6 +351,7 @@ impl From for kamu_core::auth::AccountInfo { account_type: AccountType::User, display_name: value.name.or_else(|| Some(value.login)).unwrap(), avatar_url: value.avatar_url, + is_admin: false, } } } diff --git a/src/app/cli/src/app.rs b/src/app/cli/src/app.rs index accae1b85..4325342e3 100644 --- a/src/app/cli/src/app.rs +++ b/src/app/cli/src/app.rs @@ -42,18 +42,20 @@ pub async fn run( workspace_layout: WorkspaceLayout, matches: clap::ArgMatches, ) -> Result<(), CLIError> { - // Always capture backtraces for logging - we will separately decide wheter to - // display them to the user based on verbocity level + // Always capture backtraces for logging - we will separately decide whether to + // display them to the user based on verbosity level if std::env::var_os("RUST_BACKTRACE").is_none() { std::env::set_var("RUST_BACKTRACE", "1"); } let workspace_svc = WorkspaceService::new(Arc::new(workspace_layout.clone())); let workspace_version = workspace_svc.workspace_version()?; + let config = load_config(&workspace_layout); let current_account = accounts::AccountService::current_account_indication( &matches, workspace_svc.is_multi_tenant_workspace(), + config.users.as_ref().unwrap(), ); prepare_run_dir(&workspace_layout.run_info_dir); @@ -85,7 +87,6 @@ pub async fn run( "Initializing kamu-cli" ); - let config = load_config(&workspace_layout); register_config_in_catalog( &config, &mut base_catalog_builder, @@ -315,7 +316,6 @@ fn load_config(workspace_layout: &WorkspaceLayout) -> config::CLIConfig { config } -// Public only for tests pub fn register_config_in_catalog( config: &config::CLIConfig, catalog_builder: &mut CatalogBuilder, diff --git a/src/app/cli/src/cli_commands.rs b/src/app/cli/src/cli_commands.rs index ab6017ef5..5be232755 100644 --- a/src/app/cli/src/cli_commands.rs +++ b/src/app/cli/src/cli_commands.rs @@ -12,6 +12,7 @@ use opendatafabric::*; use url::Url; use crate::commands::*; +use crate::config::UsersConfig; use crate::{accounts, odf_server, CommandInterpretationFailed, WorkspaceService}; pub fn get_command( @@ -22,12 +23,15 @@ pub fn get_command( let command: Box = match arg_matches.subcommand() { Some(("add", submatches)) => { let workspace_svc = cli_catalog.get_one::()?; + let user_config = cli_catalog.get_one::()?; + Box::new(AddCommand::new( cli_catalog.get_one()?, cli_catalog.get_one()?, accounts::AccountService::current_account_indication( &arg_matches, workspace_svc.is_multi_tenant_workspace(), + user_config.as_ref(), ), submatches .get_many("manifest") @@ -182,12 +186,15 @@ pub fn get_command( }, Some(("list", submatches)) => { let workspace_svc = cli_catalog.get_one::()?; + let user_config = cli_catalog.get_one::()?; + Box::new(ListCommand::new( cli_catalog.get_one()?, cli_catalog.get_one()?, accounts::AccountService::current_account_indication( &arg_matches, workspace_svc.is_multi_tenant_workspace(), + user_config.as_ref(), ), accounts::AccountService::related_account_indication(submatches), cli_catalog.get_one()?, diff --git a/src/app/cli/src/cli_parser.rs b/src/app/cli/src/cli_parser.rs index 24b7239fa..d9400bad8 100644 --- a/src/app/cli/src/cli_parser.rs +++ b/src/app/cli/src/cli_parser.rs @@ -12,7 +12,7 @@ use std::net::IpAddr; use clap::{value_parser, Arg, ArgAction, Command}; use opendatafabric::*; -fn tabular_output_params<'a>(app: Command) -> Command { +fn tabular_output_params(app: Command) -> Command { app.args([ Arg::new("output-format") .long("output-format") @@ -650,7 +650,7 @@ pub fn cli() -> Command { .args([ Arg::new("address") .long("address") - .value_parser(value_parser!(std::net::IpAddr)) + .value_parser(value_parser!(IpAddr)) .help("Expose HTTP server on specific network interface"), Arg::new("http-port") .long("http-port") @@ -1147,7 +1147,7 @@ pub fn cli() -> Command { .args([ Arg::new("address") .long("address") - .value_parser(value_parser!(std::net::IpAddr)) + .value_parser(value_parser!(IpAddr)) .help("Bind to a specific network interface"), Arg::new("http-port") .long("http-port") @@ -1232,7 +1232,7 @@ pub fn cli() -> Command { .args([ Arg::new("address") .long("address") - .value_parser(value_parser!(std::net::IpAddr)) + .value_parser(value_parser!(IpAddr)) .help("Expose HTTP server on specific network interface"), Arg::new("http-port") .long("http-port") diff --git a/src/app/cli/src/services/accounts/account_service.rs b/src/app/cli/src/services/accounts/account_service.rs index 96777be5a..be53d662d 100644 --- a/src/app/cli/src/services/accounts/account_service.rs +++ b/src/app/cli/src/services/accounts/account_service.rs @@ -27,7 +27,7 @@ pub const LOGIN_METHOD_PASSWORD: &str = "password"; ///////////////////////////////////////////////////////////////////////////////////////// pub struct AccountService { - pub predefined_accounts: HashMap, + pub predefined_accounts: HashMap, pub allow_login_unknown: bool, } @@ -35,7 +35,8 @@ pub struct AccountService { #[interface(dyn auth::AuthenticationProvider)] impl AccountService { pub fn new(users_config: Arc) -> Self { - let mut predefined_accounts: HashMap = HashMap::new(); + let mut predefined_accounts: HashMap = HashMap::new(); + for predefined_account in &users_config.predefined { predefined_accounts.insert( predefined_account.account_name.to_string(), @@ -49,16 +50,16 @@ impl AccountService { } } - fn default_account_name(multitenant_workspace: bool) -> String { - if multitenant_workspace { + fn default_account_name(multi_tenant_workspace: bool) -> String { + if multi_tenant_workspace { whoami::username() } else { String::from(auth::DEFAULT_ACCOUNT_NAME) } } - fn default_user_name(multitenant_workspace: bool) -> String { - if multitenant_workspace { + fn default_user_name(multi_tenant_workspace: bool) -> String { + if multi_tenant_workspace { whoami::realname() } else { String::from(auth::DEFAULT_ACCOUNT_NAME) @@ -67,13 +68,13 @@ impl AccountService { pub fn current_account_indication( arg_matches: &ArgMatches, - multitenant_workspace: bool, + multi_tenant_workspace: bool, + users_config: &UsersConfig, ) -> CurrentAccountIndication { - let default_account_name: String = - AccountService::default_account_name(multitenant_workspace); - let default_user_name: String = AccountService::default_user_name(multitenant_workspace); + let (current_account, user_name, specified_explicitly) = { + let default_account_name = AccountService::default_account_name(multi_tenant_workspace); + let default_user_name = AccountService::default_user_name(multi_tenant_workspace); - let (current_account, user_name, specified_explicitly) = if let Some(account) = arg_matches.get_one::("account") { ( account.clone(), @@ -87,9 +88,20 @@ impl AccountService { ) } else { (default_account_name, default_user_name, false) - }; + } + }; + + let is_admin = if multi_tenant_workspace { + users_config + .predefined + .iter() + .find(|a| a.account_name.as_str().eq(¤t_account)) + .map_or(false, |a| a.is_admin) + } else { + true + }; - CurrentAccountIndication::new(current_account, user_name, specified_explicitly) + CurrentAccountIndication::new(current_account, user_name, specified_explicitly, is_admin) } pub fn related_account_indication(sub_matches: &ArgMatches) -> RelatedAccountIndication { @@ -107,7 +119,7 @@ impl AccountService { RelatedAccountIndication::new(target_account) } - fn find_account_info_impl(&self, account_name: &String) -> Option { + fn find_account_info_impl(&self, account_name: &String) -> Option { // The account might be predefined in the configuration self.predefined_accounts .get(account_name) @@ -117,7 +129,7 @@ impl AccountService { fn get_account_info_impl( &self, account_name: &String, - ) -> Result { + ) -> Result { // The account might be predefined in the configuration match self.predefined_accounts.get(account_name) { // Use the predefined record @@ -127,12 +139,13 @@ impl AccountService { // If configuration allows login unknown users, pretend this is an unknown user // without avatar and with the name identical to login if self.allow_login_unknown { - Ok(auth::AccountInfo { + Ok(AccountInfo { account_id: FAKE_ACCOUNT_ID.to_string(), account_name: AccountName::new_unchecked(account_name), account_type: AccountType::User, display_name: account_name.clone(), avatar_url: None, + is_admin: false, }) } else { // Otherwise we don't recognized this user between predefined @@ -198,7 +211,7 @@ impl auth::AuthenticationProvider for AccountService { async fn account_info_by_token( &self, provider_credentials_json: String, - ) -> Result { + ) -> Result { let provider_credentials = serde_json::from_str::( &provider_credentials_json.as_str(), ) diff --git a/src/app/cli/src/services/accounts/models.rs b/src/app/cli/src/services/accounts/models.rs index 5d2cbf580..048669f9f 100644 --- a/src/app/cli/src/services/accounts/models.rs +++ b/src/app/cli/src/services/accounts/models.rs @@ -42,10 +42,16 @@ pub struct CurrentAccountIndication { pub account_name: AccountName, pub user_name: String, pub specified_explicitly: bool, + is_admin: bool, } impl CurrentAccountIndication { - pub fn new(account_name: A, user_name: U, specified_explicitly: bool) -> Self + pub fn new( + account_name: A, + user_name: U, + specified_explicitly: bool, + is_admin: bool, + ) -> Self where A: Into, U: Into, @@ -54,6 +60,7 @@ impl CurrentAccountIndication { account_name: AccountName::try_from(account_name.into()).unwrap(), user_name: user_name.into(), specified_explicitly, + is_admin, } } @@ -62,7 +69,7 @@ impl CurrentAccountIndication { } pub fn to_current_account_subject(&self) -> CurrentAccountSubject { - CurrentAccountSubject::logged(AccountName::from(self.account_name.clone())) + CurrentAccountSubject::logged(AccountName::from(self.account_name.clone()), self.is_admin) } } diff --git a/src/app/cli/src/services/config/models.rs b/src/app/cli/src/services/config/models.rs index 78efb283f..6f3a349ef 100644 --- a/src/app/cli/src/services/config/models.rs +++ b/src/app/cli/src/services/config/models.rs @@ -333,6 +333,7 @@ impl UsersConfig { account_type: AccountType::User, display_name: String::from(auth::DEFAULT_ACCOUNT_NAME), avatar_url: Some(String::from(auth::DEFAULT_AVATAR_URL)), + is_admin: true, }], allow_login_unknown: Some(false), } diff --git a/src/app/cli/tests/utils/kamu.rs b/src/app/cli/tests/utils/kamu.rs index 2b4abaecc..cc82007b0 100644 --- a/src/app/cli/tests/utils/kamu.rs +++ b/src/app/cli/tests/utils/kamu.rs @@ -34,7 +34,10 @@ impl Kamu { pub fn new>(workspace_path: P) -> Self { let workspace_path = workspace_path.into(); let workspace_layout = WorkspaceLayout::new(workspace_path.join(".kamu")); - let current_account = accounts::CurrentAccountIndication::new("kamu", "kamu", false); + let specified_explicitly = false; + let is_admin = false; + let current_account = + accounts::CurrentAccountIndication::new("kamu", "kamu", specified_explicitly, is_admin); Self { workspace_layout, current_account, diff --git a/src/domain/core/src/auth/authentication_common.rs b/src/domain/core/src/auth/authentication_common.rs index 896c5caf5..b4e5c1f11 100644 --- a/src/domain/core/src/auth/authentication_common.rs +++ b/src/domain/core/src/auth/authentication_common.rs @@ -30,6 +30,8 @@ pub struct AccountInfo { pub account_type: AccountType, pub display_name: AccountDisplayName, pub avatar_url: Option, + #[serde(default)] + pub is_admin: bool, } #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] @@ -48,6 +50,7 @@ impl AccountInfo { account_type: AccountType::User, display_name: DEFAULT_ACCOUNT_NAME.to_string(), avatar_url: Some(DEFAULT_AVATAR_URL.to_string()), + is_admin: false, } } } diff --git a/src/domain/core/src/entities/current_account_subject.rs b/src/domain/core/src/entities/current_account_subject.rs index 005e3bbb2..6a566563a 100644 --- a/src/domain/core/src/entities/current_account_subject.rs +++ b/src/domain/core/src/entities/current_account_subject.rs @@ -22,6 +22,7 @@ pub enum CurrentAccountSubject { #[derive(Debug, Clone)] pub struct LoggedAccount { pub account_name: AccountName, + pub is_admin: bool, } #[derive(Debug)] @@ -36,12 +37,17 @@ impl CurrentAccountSubject { Self::Anonymous(reason) } - pub fn logged(account_name: AccountName) -> Self { - Self::Logged(LoggedAccount { account_name }) + pub fn logged(account_name: AccountName, is_admin: bool) -> Self { + Self::Logged(LoggedAccount { + account_name, + is_admin, + }) } pub fn new_test() -> Self { - Self::logged(AccountName::new_unchecked(DEFAULT_ACCOUNT_NAME)) + let is_admin = false; + + Self::logged(AccountName::new_unchecked(DEFAULT_ACCOUNT_NAME), is_admin) } } diff --git a/src/domain/core/src/services/query_service.rs b/src/domain/core/src/services/query_service.rs index 3ad256f5a..7ef711c1f 100644 --- a/src/domain/core/src/services/query_service.rs +++ b/src/domain/core/src/services/query_service.rs @@ -74,7 +74,7 @@ pub struct EngineDesc { /// Intended for use in UI for quick engine identification and selection pub name: String, /// Language and dialect this engine is using for queries - /// Indended for configuring correct code highlighting and completion + /// Indented for configuring correct code highlighting and completion pub dialect: QueryDialect, /// OCI image repository and a tag of the latest engine image, e.g. /// "ghcr.io/kamu-data/engine-datafusion:0.1.2" diff --git a/src/domain/task-system/src/entities/task_state.rs b/src/domain/task-system/src/entities/task_state.rs index 819d39d28..782556be2 100644 --- a/src/domain/task-system/src/entities/task_state.rs +++ b/src/domain/task-system/src/entities/task_state.rs @@ -17,7 +17,7 @@ use crate::*; /// Represents the state of the task at specific point in time (projection) #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskState { - /// Unique and stable identitfier of this task + /// Unique and stable identifier of this task pub task_id: TaskID, /// Life-cycle status of a task pub status: TaskStatus,