Skip to content

Commit

Permalink
Multitenant springrs (#7)
Browse files Browse the repository at this point in the history
* fix springrs controller issues

* feat: inject tenant_id in models

* change branch of rrgen

* enable injection of tenant id with tenant alias

* add template for controller and sql injection

* fix sql schema creation order

* fix: migration for default and migration for multitenancy

* fix: add schema in migration

* fix: get controller sql

* copy dependencies' files first

* feat: add support for multitenant application using row level isolation
  • Loading branch information
dinosath authored Jan 24, 2025
1 parent 31da8a8 commit e700016
Show file tree
Hide file tree
Showing 27 changed files with 471 additions and 93 deletions.
2 changes: 2 additions & 0 deletions generators/jsonschema-commons/templates/_macros.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
{"type": "string", "format": "date-time", "result": "DateTimeWithTimeZone"},
{"type": "string", "format": "date", "result": "TimeDate"},
{"type": "string", "format": "time", "result": "TimeTime"},
{"type": "string", "format": "email", "result": "String"},
{"type": "string", "format": "url", "result": "String"},
{"type": "string", "result": "String"},
{"type": "boolean", "result": "bool"},
{"type": "integer", "min": 0, "max": 255, "result": "u8"},
Expand Down
2 changes: 1 addition & 1 deletion generators/loco/templates/controller.rs.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use axum::{debug_handler,extract::Query};
{{ macros.seaorm_prelude_imports(entity)|trim }}
use crate::models::entities::{{ file_name }}::{ActiveModel, Entity, Model, Column};
use crate::models::entities::{{ file_name }}::{ActiveModel, Entity, Model};
use super::utils::ListParams;

{% if has_one_to_many_relation(entity)=='true' or has_many_to_many_relation(entity)=='true' -%}
Expand Down
2 changes: 1 addition & 1 deletion generators/loco/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ rrgen:
frontmatterSeparator: "---\n"

application:
name: loco
name: app
type: monolith
authenticationType: jwt

Expand Down
13 changes: 13 additions & 0 deletions generators/react-admin/files/vite.config.local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080'
},
},

});
2 changes: 1 addition & 1 deletion generators/seaorm-entities/templates/entity.rs.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ enum {{ name | pascal_case }}{
{% endfor -%}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "{{ table_name }}")]
#[sea_orm(schema_name="{{ values.application.name | default('app') }}", table_name = "{{ table_name }}")]
#[serde(rename_all = "camelCase")]
pub struct Model {
#[sea_orm(primary_key)]
Expand Down
22 changes: 22 additions & 0 deletions generators/spring-rs-multitenancy/Generator.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: 0.0.1
name: spring-rs-multitenancy
version: 0.0.1
description: Add multi-tenancy support to a spring-rs application
keywords:
- rust
- spring-rs
- web-framework
- rest
- multi-tenancy
dependencies:
- name: spring-rs
version: 0.0.1
url: "file://../spring-rs"
tags:
- seaorm
- rust
sources:
- https://github.com/dinosath/protypo
maintainers:
- name: Konstantinos Athanasiou
email: [email protected]
41 changes: 41 additions & 0 deletions generators/spring-rs-multitenancy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## spring-rs multitenancy generator

### Overview

The spring-rs multitenancy generator helps you set up a multitenancy architecture for your spring-rs based application.
It supports column-based multitenancy and provides configurations to manage multiple tenants efficiently.

### Key Components

1. **Configuration Files**:
- `values.yaml`: Contains the configuration for the multitenancy setup.
- `app.toml`: Contains additional configurations for the web server and database connections.

2. **Database Setup**:
- `console.sql`: SQL script to set up your database schema and insert initial data.

### Configuration Details

#### `values.yaml`

This file configures the multitenancy settings for your application.

```yaml
application:
multitenancy:
enabled: true
type: "column"
entity-alias: company
```
- `enabled`: Enable or disable multitenancy.
- `type`: Type of multitenancy. Currently supports "column".
- `entity-alias`: Alias for the tenant entity.

### Summary

1. **Clone the Repository**: Clone the generator repository to your local machine.
2. **Configure `values.yaml`**: Update the `values.yaml` file with your desired multitenancy settings.
3. **Configure `app.toml`**: Update the `app.toml` file with your web server and database configurations.
4. **Run Database Scripts**: Execute the SQL scripts in `console.sql` to set up your database.
5. **Integrate with Your Application**: Integrate the generated configurations and scripts with your Spring application.
23 changes: 23 additions & 0 deletions generators/spring-rs-multitenancy/entities/company.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Company",
"type": "object",
"order": 0,
"properties": {
"name": {
"type": "string",
"maxLength": 100,
"x-unique": true
},
"description": {
"type": "string"
},
"logo": {
"type": "string",
"format": "url"
}
},
"required": [
"name"
]
}
25 changes: 25 additions & 0 deletions generators/spring-rs-multitenancy/entities/role.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Role",
"type": "object",
"order": 1,
"properties": {
"name": {
"type": "string",
"x-unique": true
},
"description": {
"type": "string"
},
"users": {
"type": "array",
"items": {
"$ref": "User.json"
},
"x-relationship": "many-to-many"
}
},
"required": [
"name"
]
}
72 changes: 72 additions & 0 deletions generators/spring-rs-multitenancy/entities/user.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "User",
"type": "object",
"order": 1,
"properties": {
"username": {
"type": "string",
"x-unique": true
},
"pid": {
"type": "string",
"format": "uuid",
"readOnly": true
},
"email": {
"type": "string",
"format": "email",
"x-unique": true
},
"password": {
"type": "string",
"writeOnly": true
},
"apiKey": {
"type": "string",
"readOnly": true
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"resetToken": {
"type": "string",
"readOnly": true
},
"resetSentAt": {
"type": "string",
"format": "date-time",
"readOnly": true
},
"emailVerificationToken": {
"type": "string",
"readOnly": true
},
"emailVerificationSentAt": {
"type": "string",
"format": "date-time",
"readOnly": true
},
"emailVerifiedAt": {
"type": "string",
"format": "date-time",
"readOnly": true
},
"roles": {
"type": "array",
"items": {
"$ref": "Role.json"
},
"x-relationship": "many-to-many"
}
},
"required": [
"username",
"pid",
"password",
"apiKey"
]
}
90 changes: 90 additions & 0 deletions generators/spring-rs-multitenancy/files/src/controllers/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use sea_orm::{ColumnTrait, DatabaseTransaction, Statement, TransactionTrait, ConnectionTrait};
use sea_orm::QueryFilter;
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, ModelTrait, Value};
use serde::Deserialize;
use std::collections::HashSet;
use spring_web::error::KnownWebError;

#[derive(Deserialize, Debug)]
pub struct ListParams {
#[serde(default, deserialize_with = "deserialize_ids")]
pub ids: Option<Vec<i64>>,
pub offset: Option<i64>,
pub limit: Option<i64>,
}

pub fn deserialize_ids<'de, D>(deserializer: D) -> Result<Option<Vec<i64>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(s) => {
let ids = s.split(',')
.map(str::parse::<i64>)
.collect::<Result<Vec<_>, _>>()
.map_err(serde::de::Error::custom)?;
Ok(Some(ids))
}
None => Ok(None),
}
}

pub async fn update_relation_with_diff<E, A>(
db: &DatabaseTransaction,
id: i32,
new_ids: HashSet<i32>,
entity: E,
id_column: E::Column,
relation_id_column: E::Column,
active_model_fn: impl Fn(i32, i32) -> A,
) -> Result<(), DbErr>
where
E: EntityTrait,
E::Model: Sync,
E::Model: ModelTrait,
A: ActiveModelTrait<Entity = E>,
{
let existing_lists: HashSet<i32> = E::find()
.filter(id_column.eq(id))
.all(db)
.await?
.iter()
.filter_map(|model| {
match model.get(relation_id_column) {
Value::Int(Some(id)) => Some(id),
_ => None,
}
})
.collect::<HashSet<i32>>();

let lists_to_insert: Vec<A> = new_ids.difference(&existing_lists)
.map(|&related_id| active_model_fn(id, related_id))
.collect();

let lists_to_delete: Vec<i32> = existing_lists.difference(&existing_lists)
.copied()
.collect();

if !lists_to_insert.is_empty() {
E::insert_many(lists_to_insert).exec(db).await?;
}

if !lists_to_delete.is_empty() {
E::delete_many()
.filter(id_column.eq(id))
.filter(relation_id_column.is_in(lists_to_delete))
.exec(db)
.await?;
}
Ok(())
}

pub async fn set_tenant(db: &DatabaseConnection, tenant_id: i32) -> Result<DatabaseTransaction, KnownWebError> {
let txn = db.begin().await.map_err(|_| KnownWebError::internal_server_error("cannot create transaction"))?;
let query_raw = format!("SET app.current_company = '{}';", tenant_id);
let query = Statement::from_string(sea_orm::DatabaseBackend::Postgres, query_raw);
txn.execute(query).await.map_err(|_| KnownWebError::internal_server_error("cannot set app.current_company"))?;

Ok(txn)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{% set file_name = '02_add_multitenancy_support.sql' -%}
{% if 'multitenancy' in values.application and 'enabled' in values.application.multitenancy and values.application.multitenancy.enabled == true and values.application.multitenancy.type == 'column' -%}
{% set tenant_name = (values.application.multitenancy.alias | default('company')) | snake_case -%}
{% set schema = values.application.name | default('app') -%}
to: {{ values.outputFolder }}/migrations/{{ file_name }}
message: "Sql file `{{ file_name }}` was added successfully."
injections:
- into: {{ values.outputFolder }}/config/app.toml
replace: "postgres:xudjf23adj213"
content: "tenant_user:password"
- into: {{ values.outputFolder }}/config/app.toml
after: "localhost:5432"
inline: true
content: "/postgres"

---
{% import "_macros.jinja" as macros -%}
CREATE OR REPLACE
FUNCTION get_current_{{ tenant_name }}() RETURNS INTEGER AS $$ SELECT
NULLIF(current_setting('app.current_{{ tenant_name }}',TRUE),'')::INTEGER
$$ LANGUAGE SQL SECURITY DEFINER;

create role tenant_user noinherit login password 'password';
GRANT USAGE ON SCHEMA {{ schema }} TO tenant_user;
GRANT all privileges on all tables in schema {{ schema }} to tenant_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA {{ schema }} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tenant_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {{ schema }} TO tenant_user;

{% for entity_name,entity in entities | items | rejectattr("0","eq", tenant_name) -%}
{% set table = entity.title | plural | snake_case -%}
ALTER TABLE {{ schema }}.{{ table }} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {{ schema }}.{{ table }} ADD COLUMN "{{ tenant_name }}_id" INTEGER NOT NULL DEFAULT get_current_company();
ALTER TABLE {{ schema }}.{{ table }} ADD FOREIGN KEY ("{{ tenant_name }}_id") REFERENCES {{ schema }}.{{ tenant_name | plural | snake_case }}(id) ;
CREATE POLICY tenant_isolation_policy ON {{ schema }}.{{ table }} USING ({{ tenant_name }}_id = get_current_{{ tenant_name }}() );


{% endfor -%}
{% endif -%}
Loading

0 comments on commit e700016

Please sign in to comment.