Skip to content

Commit

Permalink
feat: Initial Multi-Tenant Work (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
HarryET authored Nov 8, 2022
1 parent 0a78d0b commit cb66818
Show file tree
Hide file tree
Showing 33 changed files with 577 additions and 286 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ PORT=3000
LOG_LEVEL=ERROR
DATABASE_URL=postgres://user:pass@host:port/database

# Multi-Tenancy
TENANT_DATABASE_URL=
DEFAULT_TENANT_ID= # This has a default value and dosen't hold much impact to the running of echo-server

# Telemetry
TELEMETRY_ENABLED=false
TELEMETRY_GRPC_URL=http://localhost:4317
Expand Down
8 changes: 0 additions & 8 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,6 @@ jobs:
uses: WalletConnect/actions/actions/deploy-terraform/@master
env:
TF_VAR_onepassword_vault_id: ${{ secrets.ONEPASSWORD_VAULT_ID }}
TF_VAR_fcm_api_key: ${{ secrets.FCM_API_KEY }}
TF_VAR_apns_topic: ${{ secrets.APNS_TOPIC }}
TF_VAR_apns_certificate: ${{ secrets.APNS_CERTIFICATE }}
TF_VAR_apns_certificate_password: ${{ secrets.APNS_CERTIFICATE_PASSWORD }}
TF_VAR_image_version: ${{ needs.get-version.outputs.version }}
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
Expand Down Expand Up @@ -155,10 +151,6 @@ jobs:
uses: WalletConnect/actions/actions/deploy-terraform/@master
env:
TF_VAR_onepassword_vault_id: ${{ secrets.ONEPASSWORD_VAULT_ID }}
TF_VAR_fcm_api_key: ${{ secrets.FCM_API_KEY }}
TF_VAR_apns_topic: ${{ secrets.APNS_TOPIC }}
TF_VAR_apns_certificate: ${{ secrets.APNS_CERTIFICATE }}
TF_VAR_apns_certificate_password: ${{ secrets.APNS_CERTIFICATE_PASSWORD }}
TF_VAR_image_version: ${{ needs.get-version.outputs.version }}
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/ci_terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ jobs:
uses: WalletConnect/actions/actions/plan-terraform/@master
env:
TF_VAR_onepassword_vault_id: ${{ secrets.ONEPASSWORD_VAULT_ID }}
TF_VAR_fcm_api_key: ${{ secrets.FCM_API_KEY }}
TF_VAR_apns_topic: ${{ secrets.APNS_TOPIC }}
TF_VAR_apns_certificate: ${{ secrets.APNS_CERTIFICATE }}
TF_VAR_apns_certificate_password: ${{ secrets.APNS_CERTIFICATE_PASSWORD }}
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ wallet.
You also have to register the device with the instance of Echo Server once when the client_id is initially
generated. By sending a POST request to `<INSTANCE_URL>/clients` as per the [spec](./spec/spec.md).

## Multi-tenancy
Echo Server supports multi-tenancy. However, the management of tenants is delegated to an alternative service.
To enable multi-tenancy you need to specify a `TENANT_DATABASE_URL` which will then disable the single-tenant
endpoints in favour of endpoints with a `/:tenant_id` prefix e.g. `/:tenant_id/client/:id`

## Contact
If you wish to integrate Push functionality into your Wallet (only available on v2), please contact us.

Expand Down
5 changes: 5 additions & 0 deletions migrations/1667510128_add-tenant-id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
alter table public.clients
add tenant_id varchar(255) not null default '0000-0000-0000-0000';

alter table public.notifications
add tenant_id varchar(255) not null default '0000-0000-0000-0000';
2 changes: 1 addition & 1 deletion migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ This folder contains migrations for Echo Server and they are automatically calle
```

## Contributors
To create a new migration run `./new.sh [description]` to make a new client
To create a new migration run `./new.sh [description]` to make a new migration
2 changes: 1 addition & 1 deletion spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ This is sent whenever echo server encounters an error.
The `fields` property will only be available if it is a user provided field that caused the error.
The `errors` property will only be available when we can provide more generic errors e.g. Postgres error.

> **Note** `location` should be treated as an enum (`body`, `query`, `header`) where `body` is the
> **Note** `location` should be treated as an enum (`body`, `query`, `header`, `path`) where `body` is the
> default unless otherwise specified.
## Requests
Expand Down
40 changes: 38 additions & 2 deletions src/env.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::error::Error::InvalidConfiguration;
use crate::{error, providers::ProviderKind};
use serde::Deserialize;
use std::str::FromStr;
Expand All @@ -8,7 +9,12 @@ pub struct Config {
pub port: u16,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_relay_url")]
pub relay_url: String,
pub database_url: String,
pub tenant_database_url: Option<String>,
#[serde(default = "default_tenant_id")]
pub default_tenant_id: String,

// TELEMETRY
pub telemetry_enabled: Option<bool>,
Expand All @@ -27,11 +33,31 @@ pub struct Config {
}

impl Config {
/// Run validations against config and throw error
pub fn is_valid(&self) -> error::Result<()> {
if self.tenant_database_url.is_none() && self.single_tenant_supported_providers().is_empty()
{
return Err(InvalidConfiguration(
"no tenant database url provided and no provider keys found".to_string(),
));
}

if !self.single_tenant_supported_providers().is_empty()
&& self.tenant_database_url.is_some()
{
return Err(InvalidConfiguration(
"tenant database and providers keys found in config".to_string(),
));
}

Ok(())
}

pub fn log_level(&self) -> tracing::Level {
tracing::Level::from_str(self.log_level.as_str()).expect("Invalid log level")
}

pub fn supported_providers(&self) -> Vec<ProviderKind> {
pub fn single_tenant_supported_providers(&self) -> Vec<ProviderKind> {
let mut supported = vec![];

if self.apns_certificate.is_some() && self.apns_certificate_password.is_some() {
Expand All @@ -44,7 +70,9 @@ impl Config {

// Only available in debug/testing
#[cfg(any(debug_assertions, test))]
supported.push(ProviderKind::Noop);
if self.tenant_database_url.is_none() {
supported.push(ProviderKind::Noop);
}

supported
}
Expand All @@ -62,6 +90,14 @@ fn default_apns_sandbox_mode() -> bool {
false
}

fn default_relay_url() -> String {
"https://relay.walletconnect.com".to_string()
}

fn default_tenant_id() -> String {
"0000-0000-0000-0000".to_string()
}

pub fn get_config() -> error::Result<Config> {
let config = envy::from_env::<Config>()?;
Ok(config)
Expand Down
48 changes: 44 additions & 4 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ pub enum Error {

#[error("neither signature or timestamp header cannot not found")]
MissingAllSignatureHeader,

#[error("single-tenant request made while echo server in multi-tenant mode")]
MissingTenantId,

#[error("multi-tenant request made while echo server in single-tenant mode")]
IncludedTenantIdWhenNotNeeded,

#[error("invalid configuration: {0}")]
InvalidConfiguration(String),

#[error("invalid tenant id: {0}")]
InvalidTenantId(String),
}

impl IntoResponse for Error {
Expand Down Expand Up @@ -213,11 +225,39 @@ impl IntoResponse for Error {
}
], vec![
ErrorField {
field: TIMESTAMP_HEADER_NAME.to_string(),
description: "Missing timestamp".to_string(),
location: ErrorLocation::Header
}
field: TIMESTAMP_HEADER_NAME.to_string(),
description: "Missing timestamp".to_string(),
location: ErrorLocation::Header
}
]),
Error::InvalidTenantId(id) => crate::handlers::Response::new_failure(StatusCode::BAD_REQUEST, vec![
ResponseError {
name: "tenant".to_string(),
message: format!("The provided Tenant ID, {}, is invalid. Please ensure it's valid and the url is in the format /:tenant_id/...path", &id),
}
], vec![
ErrorField {
field: "tenant_id".to_string(),
description: format!("Invalid Tenant ID, {}", &id),
location: ErrorLocation::Path
}
]),
Error::MissingTenantId => crate::handlers::Response::new_failure(
StatusCode::BAD_REQUEST,
vec![ResponseError {
name: "tenancy-mode".to_string(),
message: "single-tenant request made while echo server in multi-tenant mode".to_string(),
}],
vec![],
),
Error::IncludedTenantIdWhenNotNeeded => crate::handlers::Response::new_failure(
StatusCode::BAD_REQUEST,
vec![ResponseError {
name: "tenancy-mode".to_string(),
message: "multi-tenant request made while echo server in single-tenant mode".to_string(),
}],
vec![],
),
_ => crate::handlers::Response::new_failure(StatusCode::INTERNAL_SERVER_ERROR, vec![
ResponseError {
name: "unknown_error".to_string(),
Expand Down
17 changes: 10 additions & 7 deletions src/handlers/delete_client.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
use crate::error::Result;
use crate::handlers::Response;
use crate::state::AppState;
use crate::stores::client::ClientStore;
use crate::stores::notification::NotificationStore;
use axum::extract::{Path, State};
use crate::state::{AppState, State};
use axum::extract::{Path, State as StateExtractor};
use std::sync::Arc;
use crate::error::Error::IncludedTenantIdWhenNotNeeded;

pub async fn handler(
Path(id): Path<String>,
State(state): State<Arc<AppState<impl ClientStore, impl NotificationStore>>>,
Path((tenant_id, id)): Path<(String, String)>,
StateExtractor(state): StateExtractor<Arc<AppState>>,
) -> Result<Response> {
state.client_store.delete_client(&id).await?;
if state.config.default_tenant_id != tenant_id && !state.is_multitenant() {
return Err(IncludedTenantIdWhenNotNeeded)
}

state.client_store.delete_client(&tenant_id, &id).await?;

if let Some(metrics) = &state.metrics {
metrics.registered_webhooks.add(-1, &[]);
Expand Down
6 changes: 1 addition & 5 deletions src/handlers/health.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
use crate::state::AppState;
use crate::stores::client::ClientStore;
use crate::stores::notification::NotificationStore;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use std::sync::Arc;

pub async fn handler(
State(state): State<Arc<AppState<impl ClientStore, impl NotificationStore>>>,
) -> impl IntoResponse {
pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
(
StatusCode::OK,
format!("OK, echo-server v{}", state.build_info.crate_info.version),
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod delete_client;
pub mod health;
pub mod push_message;
pub mod register_client;
pub mod single_tenant_wrappers;

#[derive(serde::Serialize)]
#[serde(rename_all = "lowercase")]
Expand All @@ -15,6 +16,7 @@ pub enum ErrorLocation {
// Note (Harry): Spec supports this but it currently isn't used
//Query,
Header,
Path,
}

#[derive(serde::Serialize)]
Expand Down
25 changes: 15 additions & 10 deletions src/handlers/push_message.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
use crate::error::Result;
use crate::state::AppState;
use crate::stores::client::ClientStore;
use crate::stores::notification::NotificationStore;
use crate::middleware::validate_signature::RequireValidSignature;
use crate::state::{AppState, State};
use crate::{handlers::Response, providers::PushProvider};
use crate::{middleware::validate_signature::RequireValidSignature, providers::get_provider};
use axum::extract::{Json, Path, State};
use axum::extract::{Json, Path, State as StateExtractor};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::error::Error::IncludedTenantIdWhenNotNeeded;

#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
pub struct MessagePayload {
Expand All @@ -22,15 +21,19 @@ pub struct PushMessageBody {
}

pub async fn handler(
Path(id): Path<String>,
State(state): State<Arc<AppState<impl ClientStore, impl NotificationStore>>>,
Path((tenant_id, id)): Path<(String, String)>,
StateExtractor(state): StateExtractor<Arc<AppState>>,
RequireValidSignature(Json(body)): RequireValidSignature<Json<PushMessageBody>>,
) -> Result<Response> {
let client = state.client_store.get_client(&id).await?;
if state.config.default_tenant_id != tenant_id && !state.is_multitenant() {
return Err(IncludedTenantIdWhenNotNeeded)
}

let client = state.client_store.get_client(&tenant_id, &id).await?;

let notification = state
.notification_store
.create_or_update_notification(&body.id, &id, &body.payload)
.create_or_update_notification(&body.id, &tenant_id, &id, &body.payload)
.await?;

// TODO make better by only ignoring if previously executed successfully
Expand All @@ -39,7 +42,9 @@ pub async fn handler(
return Ok(Response::new_success(StatusCode::ACCEPTED));
}

let mut provider = get_provider(client.push_type, &state)?;
let tenant = state.tenant_store.get_tenant(&tenant_id).await?;

let mut provider = tenant.provider(&client.push_type)?;

provider
.send_notification(client.token, body.payload)
Expand Down
28 changes: 19 additions & 9 deletions src/handlers/register_client.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use crate::error::Error::{ClientAlreadyRegistered, EmptyField, ProviderNotAvailable};
use crate::error::Error::{ClientAlreadyRegistered, EmptyField, IncludedTenantIdWhenNotNeeded, ProviderNotAvailable};
use crate::error::Result;
use crate::handlers::Response;
use crate::state::AppState;
use crate::stores::client::{Client, ClientStore};
use crate::stores::notification::NotificationStore;
use crate::state::{AppState, State};
use crate::stores::client::Client;
use crate::stores::StoreError;
use axum::extract::{Json, State};
use axum::extract::{Json, Path, State as StateExtractor};
use serde::Deserialize;
use std::sync::Arc;

Expand All @@ -18,20 +17,30 @@ pub struct RegisterBody {
}

pub async fn handler(
State(state): State<Arc<AppState<impl ClientStore, impl NotificationStore>>>,
Path(tenant_id): Path<String>,
StateExtractor(state): StateExtractor<Arc<AppState>>,
Json(body): Json<RegisterBody>,
) -> Result<Response> {
if state.config.default_tenant_id != tenant_id && !state.is_multitenant() {
return Err(IncludedTenantIdWhenNotNeeded)
}

let push_type = body.push_type.as_str().try_into()?;
let supported_providers = state.supported_providers();
let tenant = state.tenant_store.get_tenant(&tenant_id).await?;
let supported_providers = tenant.providers();
if !supported_providers.contains(&push_type) {
return Err(ProviderNotAvailable(push_type.as_str().into()));
return Err(ProviderNotAvailable(push_type.into()));
}

if body.token.is_empty() {
return Err(EmptyField("token".to_string()));
}

let exists = match state.client_store.get_client(&body.client_id).await {
let exists = match state
.client_store
.get_client(&tenant_id, &body.client_id)
.await
{
Ok(_) => true,
Err(e) => match e {
StoreError::Database(db_error) => {
Expand All @@ -48,6 +57,7 @@ pub async fn handler(
state
.client_store
.create_client(
&tenant_id,
&body.client_id,
Client {
push_type,
Expand Down
Loading

0 comments on commit cb66818

Please sign in to comment.