diff --git a/rfd-api/src/context.rs b/rfd-api/src/context.rs index c96a0e4..1e1e6b6 100644 --- a/rfd-api/src/context.rs +++ b/rfd-api/src/context.rs @@ -13,9 +13,10 @@ use rfd_model::{ storage::{ AccessGroupFilter, AccessGroupStore, AccessTokenStore, ApiKeyFilter, ApiKeyStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, ApiUserStore, JobStore, - ListPagination, LoginAttemptFilter, LoginAttemptStore, OAuthClientFilter, - OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, RfdFilter, - RfdPdfFilter, RfdPdfStore, RfdRevisionFilter, RfdRevisionStore, RfdStore, StoreError, + ListPagination, LoginAttemptFilter, LoginAttemptStore, MapperFilter, MapperStore, + OAuthClientFilter, OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, + RfdFilter, RfdPdfFilter, RfdPdfStore, RfdRevisionFilter, RfdRevisionStore, RfdStore, + StoreError, }, AccessGroup, AccessToken, ApiUser, ApiUserProvider, InvalidValueError, Job, LoginAttempt, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, @@ -42,7 +43,7 @@ use crate::{ LoginError, UserInfo, }, error::{ApiError, AppError}, - mapper::{ApiPermissionMapper, GroupMapper}, + mapper::{MapperRule, MapperRules}, permissions::{ApiPermission, PermissionStorage}, util::response::{client_error, internal_error}, ApiCaller, ApiPermissions, User, UserToken, @@ -64,6 +65,7 @@ pub trait Storage: + OAuthClientSecretStore + OAuthClientRedirectUriStore + AccessGroupStore + + MapperStore + Send + Sync + 'static @@ -83,6 +85,7 @@ impl Storage for T where + OAuthClientSecretStore + OAuthClientRedirectUriStore + AccessGroupStore + + MapperStore + Send + Sync + 'static @@ -98,7 +101,6 @@ pub struct ApiContext { pub secrets: SecretContext, pub oauth_providers: HashMap>, pub search: SearchContext, - pub mappers: MapperContext, } pub struct JwtContext { @@ -118,11 +120,6 @@ pub struct SearchContext { pub index: String, } -pub struct MapperContext { - pub direct_permissions: Vec>, - pub groups: Vec>, -} - pub struct RegisteredAccessToken { pub access_token: AccessToken, pub signed_token: String, @@ -225,10 +222,6 @@ impl ApiContext { client: SearchClient::new(search.host, search.key), index: search.index, }, - mappers: MapperContext { - direct_permissions: vec![], - groups: vec![], - }, }) } @@ -539,15 +532,11 @@ impl ApiContext { .await?; let mut mapped_permissions = ApiPermissions::new(); - - for mapper in &self.mappers.direct_permissions { - mapped_permissions.append(&mut mapper.permissions_for(&self, &info).await); - } - let mut mapped_groups = vec![]; - for mapper in &self.mappers.groups { - mapped_groups.append(&mut mapper.groups_for(&self, &info).await); + for mapper in &self.get_mappers().await? { + mapped_permissions.append(&mut mapper.permissions_for(&self, &info).await?); + mapped_groups.append(&mut mapper.groups_for(&self, &info).await?); } match api_user_providers.len() { @@ -596,16 +585,23 @@ impl ApiContext { async fn ensure_api_user( &self, api_user_id: Uuid, - mapped_permissions: ApiPermissions, - mapped_grous: Vec, + mut mapped_permissions: ApiPermissions, + mut mapped_groups: Vec, ) -> Result { match self.get_api_user(&api_user_id).await? { - Some(api_user) => Ok(api_user), + Some(api_user) => { + // Ensure that the existing user has "at least" the mapped permissions + let mut update: NewApiUser = api_user.into(); + update.permissions.append(&mut mapped_permissions); + update.groups.append(&mut mapped_groups); + + Ok(ApiUserStore::upsert(&*self.storage, update).await?) + } None => self .update_api_user(NewApiUser { id: api_user_id, permissions: mapped_permissions, - groups: mapped_grous, + groups: mapped_groups, }) .await .map_err(ApiError::Storage) @@ -992,6 +988,26 @@ impl ApiContext { None }) } + + // Mapper Operations + + pub async fn get_mappers(&self) -> Result, StoreError> { + Ok(MapperStore::list( + &*self.storage, + MapperFilter::default(), + &ListPagination::default().limit(UNLIMITED), + ) + .await? + .into_iter() + .filter_map(|mapper| { + serde_json::from_value::(mapper.rule) + .map_err(|err| { + tracing::error!(?err, "Failed to translate stored rule to mapper"); + }) + .ok() + }) + .collect::>()) + } } #[cfg(test)] @@ -1158,15 +1174,15 @@ pub(crate) mod test_mocks { permissions::Caller, storage::{ AccessGroupStore, AccessTokenStore, ApiKeyStore, ApiUserProviderStore, ApiUserStore, - JobStore, ListPagination, LoginAttemptStore, MockAccessGroupStore, + JobStore, ListPagination, LoginAttemptStore, MapperStore, MockAccessGroupStore, MockAccessTokenStore, MockApiKeyStore, MockApiUserProviderStore, MockApiUserStore, - MockJobStore, MockLoginAttemptStore, MockOAuthClientRedirectUriStore, + MockJobStore, MockLoginAttemptStore, MockMapperStore, MockOAuthClientRedirectUriStore, MockOAuthClientSecretStore, MockOAuthClientStore, MockRfdPdfStore, MockRfdRevisionStore, MockRfdStore, OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, RfdPdfStore, RfdRevisionStore, RfdStore, }, ApiKey, ApiUserProvider, NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, - NewApiUserProvider, NewJob, NewLoginAttempt, NewRfd, NewRfdPdf, NewRfdRevision, + NewApiUserProvider, NewJob, NewLoginAttempt, NewMapper, NewRfd, NewRfdPdf, NewRfdRevision, }; use std::sync::Arc; @@ -1227,6 +1243,7 @@ pub(crate) mod test_mocks { pub oauth_client_secret_store: Option>, pub oauth_client_redirect_uri_store: Option>, pub access_group_store: Option>>, + pub mapper_store: Option>, } impl MockStorage { @@ -1246,6 +1263,7 @@ pub(crate) mod test_mocks { oauth_client_secret_store: None, oauth_client_redirect_uri_store: None, access_group_store: None, + mapper_store: None, } } } @@ -1766,4 +1784,41 @@ pub(crate) mod test_mocks { self.access_group_store.as_ref().unwrap().delete(id).await } } + + #[async_trait] + impl MapperStore for MockStorage { + async fn get( + &self, + id: &uuid::Uuid, + deleted: bool, + ) -> Result, rfd_model::storage::StoreError> { + self.mapper_store.as_ref().unwrap().get(id, deleted).await + } + + async fn list( + &self, + filter: rfd_model::storage::MapperFilter, + pagination: &ListPagination, + ) -> Result, rfd_model::storage::StoreError> { + self.mapper_store + .as_ref() + .unwrap() + .list(filter, pagination) + .await + } + + async fn upsert( + &self, + new_mapper: &NewMapper, + ) -> Result { + self.mapper_store.as_ref().unwrap().upsert(new_mapper).await + } + + async fn delete( + &self, + id: &uuid::Uuid, + ) -> Result, rfd_model::storage::StoreError> { + self.mapper_store.as_ref().unwrap().delete(id).await + } + } } diff --git a/rfd-api/src/initial_data.rs b/rfd-api/src/initial_data.rs new file mode 100644 index 0000000..30245c3 --- /dev/null +++ b/rfd-api/src/initial_data.rs @@ -0,0 +1,47 @@ +use config::{Config, ConfigError, Environment, File}; +use rfd_model::{storage::StoreError, NewAccessGroup}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{context::ApiContext, mapper::MapperRules, ApiPermissions}; + +#[derive(Debug, Deserialize)] +pub struct InitialData { + pub groups: Vec, + pub mappers: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct InitialGroup { + pub name: String, + pub permissions: ApiPermissions, +} + +impl InitialData { + pub fn new() -> Result { + let config = Config::builder() + .add_source(File::with_name("baseline.toml").required(false)) + .add_source(File::with_name("rfd-api/baseline.toml").required(false)) + .add_source(Environment::default()) + .build()?; + + config.try_deserialize() + } + + pub async fn initialize(&self, ctx: &ApiContext) -> Result<(), StoreError> { + for group in &self.groups { + ctx.create_group(NewAccessGroup { + id: Uuid::new_v4(), + name: group.name.clone(), + permissions: group.permissions.clone(), + }) + .await?; + } + + for _mapper in &self.mappers { + // TODO: Configure initial mappers + } + + Ok(()) + } +} diff --git a/rfd-api/src/main.rs b/rfd-api/src/main.rs index 1b7e080..3e58712 100644 --- a/rfd-api/src/main.rs +++ b/rfd-api/src/main.rs @@ -19,6 +19,7 @@ use crate::{ endpoints::login::oauth::{ github::GitHubOAuthProvider, google::GoogleOAuthProvider, OAuthProviderName, }, + initial_data::InitialData, }; mod authn; @@ -27,6 +28,7 @@ mod context; mod email_validator; mod endpoints; mod error; +mod initial_data; mod mapper; mod permissions; mod server; @@ -67,6 +69,9 @@ async fn main() -> Result<(), Box> { ) .await?; + let init_data = InitialData::new()?; + init_data.initialize(&context).await?; + if let Some(github) = config.authn.oauth.github { context.insert_oauth_provider( OAuthProviderName::GitHub, diff --git a/rfd-api/src/mapper.rs b/rfd-api/src/mapper.rs index 39110b9..ef3ad1c 100644 --- a/rfd-api/src/mapper.rs +++ b/rfd-api/src/mapper.rs @@ -1,14 +1,82 @@ use async_trait::async_trait; +use rfd_model::storage::StoreError; +use serde::Deserialize; use uuid::Uuid; use crate::{context::ApiContext, endpoints::login::UserInfo, ApiPermissions}; #[async_trait] -pub trait ApiPermissionMapper: Send + Sync { - async fn permissions_for(&self, ctx: &ApiContext, user: &UserInfo) -> ApiPermissions; +pub trait MapperRule: Send + Sync { + async fn permissions_for( + &self, + ctx: &ApiContext, + user: &UserInfo, + ) -> Result; + async fn groups_for(&self, ctx: &ApiContext, user: &UserInfo) -> Result, StoreError>; +} + +#[derive(Debug, Deserialize)] +pub enum MapperRules { + EmailDomain(EmailDomainMapper), +} + +#[derive(Debug, Deserialize)] +pub struct EmailDomainMapper { + domain: String, + groups: Vec, +} + +#[async_trait] +impl MapperRule for EmailDomainMapper { + async fn permissions_for( + &self, + _ctx: &ApiContext, + _user: &UserInfo, + ) -> Result { + Ok(ApiPermissions::new()) + } + + async fn groups_for(&self, ctx: &ApiContext, user: &UserInfo) -> Result, StoreError> { + let has_email_in_domain = user + .verified_emails + .iter() + .fold(false, |found, email| found || email.ends_with(&self.domain)); + + if has_email_in_domain { + let groups = ctx + .get_groups() + .await? + .into_iter() + .filter_map(|group| { + if self.groups.contains(&group.name) { + Some(group.id) + } else { + None + } + }) + .collect::>(); + Ok(groups) + } else { + Ok(vec![]) + } + } } #[async_trait] -pub trait GroupMapper: Send + Sync { - async fn groups_for(&self, ctx: &ApiContext, user: &UserInfo) -> Vec; +impl MapperRule for MapperRules { + async fn permissions_for( + &self, + ctx: &ApiContext, + user: &UserInfo, + ) -> Result { + match self { + Self::EmailDomain(rule) => rule.permissions_for(ctx, user).await, + } + } + + async fn groups_for(&self, ctx: &ApiContext, user: &UserInfo) -> Result, StoreError> { + match self { + Self::EmailDomain(rule) => rule.groups_for(ctx, user).await, + } + } } diff --git a/rfd-model/migrations/2023-09-29-200104_add_mappers/down.sql b/rfd-model/migrations/2023-09-29-200104_add_mappers/down.sql new file mode 100644 index 0000000..8fe59c5 --- /dev/null +++ b/rfd-model/migrations/2023-09-29-200104_add_mappers/down.sql @@ -0,0 +1 @@ +DROP TABLE mapper; \ No newline at end of file diff --git a/rfd-model/migrations/2023-09-29-200104_add_mappers/up.sql b/rfd-model/migrations/2023-09-29-200104_add_mappers/up.sql new file mode 100644 index 0000000..14b92dd --- /dev/null +++ b/rfd-model/migrations/2023-09-29-200104_add_mappers/up.sql @@ -0,0 +1,7 @@ +CREATE TABLE mapper ( + id UUID PRIMARY KEY, + name VARCHAR NOT NULL UNIQUE, + rule JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); \ No newline at end of file diff --git a/rfd-model/src/db.rs b/rfd-model/src/db.rs index dcd1573..2ed8b0c 100644 --- a/rfd-model/src/db.rs +++ b/rfd-model/src/db.rs @@ -1,14 +1,15 @@ use chrono::{DateTime, Utc}; use diesel::{Insertable, Queryable}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use uuid::Uuid; use crate::{ permissions::Permissions, schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_provider, job, - login_attempt, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, rfd_pdf, - rfd_revision, + login_attempt, mapper, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, + rfd_pdf, rfd_revision, }, schema_ext::{ContentFormat, LoginAttemptState, PdfSource}, }; @@ -178,3 +179,13 @@ pub struct AccessGroupModel { pub updated_at: DateTime, pub deleted_at: Option>, } + +#[derive(Debug, Deserialize, Serialize, Queryable, Insertable)] +#[diesel(table_name = mapper)] +pub struct MapperModel { + pub id: Uuid, + pub name: String, + pub rule: Value, + pub created_at: DateTime, + pub deleted_at: Option>, +} diff --git a/rfd-model/src/lib.rs b/rfd-model/src/lib.rs index 1fc3984..09e828a 100644 --- a/rfd-model/src/lib.rs +++ b/rfd-model/src/lib.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, fmt::Display}; use chrono::{DateTime, Utc}; use db::{ - AccessGroupModel, JobModel, LoginAttemptModel, OAuthClientRedirectUriModel, + AccessGroupModel, JobModel, LoginAttemptModel, MapperModel, OAuthClientRedirectUriModel, OAuthClientSecretModel, RfdModel, RfdPdfModel, RfdRevisionModel, }; use partial_struct::partial; @@ -10,6 +10,7 @@ use permissions::Permissions; use schema_ext::{ContentFormat, LoginAttemptState, PdfSource}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json::Value; use thiserror::Error; use uuid::Uuid; @@ -416,3 +417,27 @@ where } } } + +#[partial(NewMapper)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Mapper { + pub id: Uuid, + pub name: String, + pub rule: Value, + #[partial(NewMapper(skip))] + pub created_at: DateTime, + #[partial(NewMapper(skip))] + pub deleted_at: Option>, +} + +impl From for Mapper { + fn from(value: MapperModel) -> Self { + Mapper { + id: value.id, + name: value.name, + rule: value.rule, + created_at: value.created_at, + deleted_at: value.deleted_at, + } + } +} diff --git a/rfd-model/src/schema.rs b/rfd-model/src/schema.rs index 9443111..391534a 100644 --- a/rfd-model/src/schema.rs +++ b/rfd-model/src/schema.rs @@ -111,6 +111,16 @@ diesel::table! { } } +diesel::table! { + mapper (id) { + id -> Uuid, + name -> Varchar, + rule -> Jsonb, + created_at -> Timestamptz, + deleted_at -> Nullable, + } +} + diesel::table! { oauth_client (id) { id -> Uuid, @@ -203,6 +213,7 @@ diesel::allow_tables_to_appear_in_same_query!( api_user_provider, job, login_attempt, + mapper, oauth_client, oauth_client_redirect_uri, oauth_client_secret, diff --git a/rfd-model/src/storage/mod.rs b/rfd-model/src/storage/mod.rs index 41de47b..879a443 100644 --- a/rfd-model/src/storage/mod.rs +++ b/rfd-model/src/storage/mod.rs @@ -11,11 +11,11 @@ use uuid::Uuid; use crate::{ permissions::Permission, schema_ext::{LoginAttemptState, PdfSource}, - AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, NewAccessGroup, - NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, NewLoginAttempt, - NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, NewRfd, NewRfdPdf, - NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, RfdPdf, - RfdRevision, + AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, Mapper, + NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, + NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, + NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, + RfdPdf, RfdRevision, }; pub mod postgres; @@ -401,3 +401,23 @@ pub trait AccessGroupStore { async fn upsert(&self, group: &NewAccessGroup) -> Result, StoreError>; async fn delete(&self, id: &Uuid) -> Result>, StoreError>; } + +#[derive(Debug, Default, PartialEq)] +pub struct MapperFilter { + pub id: Option>, + pub name: Option>, + pub deleted: bool, +} + +#[cfg_attr(feature = "mock", automock)] +#[async_trait] +pub trait MapperStore { + async fn get(&self, id: &Uuid, deleted: bool) -> Result, StoreError>; + async fn list( + &self, + filter: MapperFilter, + pagination: &ListPagination, + ) -> Result, StoreError>; + async fn upsert(&self, new_mapper: &NewMapper) -> Result; + async fn delete(&self, id: &Uuid) -> Result, StoreError>; +} diff --git a/rfd-model/src/storage/postgres.rs b/rfd-model/src/storage/postgres.rs index dd6e1cb..59ce86c 100644 --- a/rfd-model/src/storage/postgres.rs +++ b/rfd-model/src/storage/postgres.rs @@ -19,29 +19,30 @@ use uuid::Uuid; use crate::{ db::{ AccessGroupModel, ApiKeyModel, ApiUserAccessTokenModel, ApiUserModel, ApiUserProviderModel, - JobModel, LoginAttemptModel, OAuthClientModel, OAuthClientRedirectUriModel, + JobModel, LoginAttemptModel, MapperModel, OAuthClientModel, OAuthClientRedirectUriModel, OAuthClientSecretModel, RfdModel, RfdPdfModel, RfdRevisionModel, }, permissions::{Permission, Permissions}, schema::{ access_groups, api_key, api_user, api_user_access_token, api_user_provider, job, - login_attempt, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, rfd_pdf, - rfd_revision, + login_attempt, mapper, oauth_client, oauth_client_redirect_uri, oauth_client_secret, rfd, + rfd_pdf, rfd_revision, }, storage::StoreError, - AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, NewAccessGroup, - NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, NewLoginAttempt, - NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, NewRfd, NewRfdPdf, - NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, RfdPdf, - RfdRevision, + AccessGroup, AccessToken, ApiKey, ApiUser, ApiUserProvider, Job, LoginAttempt, Mapper, + NewAccessGroup, NewAccessToken, NewApiKey, NewApiUser, NewApiUserProvider, NewJob, + NewLoginAttempt, NewMapper, NewOAuthClient, NewOAuthClientRedirectUri, NewOAuthClientSecret, + NewRfd, NewRfdPdf, NewRfdRevision, OAuthClient, OAuthClientRedirectUri, OAuthClientSecret, Rfd, + RfdPdf, RfdRevision, }; use super::{ AccessGroupFilter, AccessGroupStore, AccessTokenFilter, AccessTokenStore, ApiKeyFilter, ApiKeyStore, ApiUserFilter, ApiUserProviderFilter, ApiUserProviderStore, ApiUserStore, - JobFilter, JobStore, ListPagination, LoginAttemptFilter, LoginAttemptStore, OAuthClientFilter, - OAuthClientRedirectUriStore, OAuthClientSecretStore, OAuthClientStore, RfdFilter, RfdPdfFilter, - RfdPdfStore, RfdRevisionFilter, RfdRevisionStore, RfdStore, + JobFilter, JobStore, ListPagination, LoginAttemptFilter, LoginAttemptStore, MapperFilter, + MapperStore, OAuthClientFilter, OAuthClientRedirectUriStore, OAuthClientSecretStore, + OAuthClientStore, RfdFilter, RfdPdfFilter, RfdPdfStore, RfdRevisionFilter, RfdRevisionStore, + RfdStore, }; pub type DbPool = Pool>; @@ -1310,3 +1311,73 @@ where AccessGroupStore::get(self, id, true).await } } + +#[async_trait] +impl MapperStore for PostgresStore { + async fn get(&self, id: &Uuid, deleted: bool) -> Result, StoreError> { + let client = MapperStore::list( + self, + MapperFilter { + id: Some(vec![*id]), + name: None, + deleted, + }, + &ListPagination::default().limit(1), + ) + .await?; + + Ok(client.into_iter().nth(0)) + } + + async fn list( + &self, + filter: MapperFilter, + pagination: &ListPagination, + ) -> Result, StoreError> { + let mut query = mapper::dsl::mapper.into_boxed(); + + let MapperFilter { id, name, deleted } = filter; + + if let Some(id) = id { + query = query.filter(mapper::id.eq_any(id)); + } + + if let Some(name) = name { + query = query.filter(mapper::name.eq_any(name)); + } + + if !deleted { + query = query.filter(mapper::deleted_at.is_null()); + } + + let results = query + .offset(pagination.offset) + .limit(pagination.limit) + .order(mapper::created_at.desc()) + .get_results_async::(&self.conn) + .await?; + + Ok(results.into_iter().map(|model| model.into()).collect()) + } + async fn upsert(&self, new_mapper: &NewMapper) -> Result { + let mapper_m: MapperModel = insert_into(mapper::dsl::mapper) + .values(( + mapper::id.eq(new_mapper.id), + mapper::name.eq(new_mapper.name.clone()), + mapper::rule.eq(new_mapper.rule.clone()), + )) + .get_result_async(&self.conn) + .await?; + + Ok(mapper_m.into()) + } + async fn delete(&self, id: &Uuid) -> Result, StoreError> { + let _ = update(mapper::dsl::mapper) + .filter(mapper::id.eq(*id)) + .set(mapper::deleted_at.eq(Utc::now())) + .execute_async(&self.conn) + .await?; + + MapperStore::get(self, id, true).await + } +}