Some checks are pending
CI Pipeline / changes (push) Waiting to run
CI Pipeline / core (push) Blocked by required conditions
CI Pipeline / trading-backend (push) Blocked by required conditions
CI Pipeline / trading-data-service (push) Blocked by required conditions
CI Pipeline / trading-frontend (push) Blocked by required conditions
CI Pipeline / erp-core (push) Blocked by required conditions
CI Pipeline / erp-mecanicas (push) Blocked by required conditions
CI Pipeline / gamilit-backend (push) Blocked by required conditions
CI Pipeline / gamilit-frontend (push) Blocked by required conditions
Core: - Add catalog reference implementations (auth, payments, notifications, websocket, etc.) - New agent profiles: Database Auditor, Integration Validator, LLM Agent, Policy Auditor, Trading Strategist - Update SIMCO directives and add escalation/git guidelines - Add deployment inventory and audit execution reports Projects: - erp-suite: DevOps configs, Dockerfiles, shared libs, vertical enhancements - gamilit: Test structure, admin controllers, service refactoring, husky/commitlint - trading-platform: MT4 gateway, auth controllers, admin frontend, deployment scripts - platform_marketing_content: Full DevOps setup, tests, Docker configs - betting-analytics/inmobiliaria-analytics: Initial app structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
675 lines
18 KiB
Markdown
675 lines
18 KiB
Markdown
# Multi-Tenancy Guide
|
|
|
|
## Overview
|
|
|
|
ERP Suite implementa **multi-tenancy a nivel de schema** usando PostgreSQL Row-Level Security (RLS) para garantizar aislamiento completo de datos entre tenants.
|
|
|
|
**Modelo:** Shared database, shared schema, **isolated by tenant_id**
|
|
|
|
## Architecture
|
|
|
|
### Tenant Isolation Strategy
|
|
|
|
```
|
|
┌─────────────────────────────────────────┐
|
|
│ Single PostgreSQL Database │
|
|
│ │
|
|
│ ┌────────────────────────────────────┐ │
|
|
│ │ Table: products.products │ │
|
|
│ │ ┌──────────────────────────────┐ │ │
|
|
│ │ │ Tenant A rows (tenant_id=A) │ │ │
|
|
│ │ ├──────────────────────────────┤ │ │
|
|
│ │ │ Tenant B rows (tenant_id=B) │ │ │
|
|
│ │ ├──────────────────────────────┤ │ │
|
|
│ │ │ Tenant C rows (tenant_id=C) │ │ │
|
|
│ │ └──────────────────────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ RLS Policy: WHERE tenant_id = │ │
|
|
│ │ current_setting('app.current_ │ │
|
|
│ │ tenant_id')::uuid │ │
|
|
│ └────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────┘
|
|
```
|
|
|
|
### Why RLS (Row-Level Security)?
|
|
|
|
**Advantages:**
|
|
- **Security at database level** - Even if application has bugs, data is isolated
|
|
- **Transparent to application** - No manual filtering in every query
|
|
- **Works with BI tools** - Reports automatically scoped to tenant
|
|
- **Audit trail** - PostgreSQL logs enforce tenant context
|
|
|
|
**Disadvantages:**
|
|
- PostgreSQL specific (not portable to MySQL/MongoDB)
|
|
- Slight performance overhead (minimal)
|
|
- Requires SET LOCAL on each connection
|
|
|
|
## Implementation
|
|
|
|
### 1. Database Schema Design
|
|
|
|
#### Tenant Table
|
|
|
|
```sql
|
|
-- Schema: auth
|
|
CREATE TABLE auth.tenants (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
slug VARCHAR(100) NOT NULL UNIQUE,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
|
plan VARCHAR(50) NOT NULL DEFAULT 'free',
|
|
settings JSONB DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
-- Index
|
|
CREATE INDEX idx_tenants_slug ON auth.tenants(slug) WHERE deleted_at IS NULL;
|
|
CREATE INDEX idx_tenants_status ON auth.tenants(status);
|
|
```
|
|
|
|
#### User-Tenant Relationship
|
|
|
|
```sql
|
|
CREATE TABLE auth.users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
email VARCHAR(255) NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
-- ...
|
|
CONSTRAINT uq_user_email_tenant UNIQUE (email, tenant_id)
|
|
);
|
|
|
|
CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id);
|
|
```
|
|
|
|
#### Standard Table Structure
|
|
|
|
**Every table** (except auth.tenants) must have:
|
|
|
|
```sql
|
|
CREATE TABLE {schema}.{table} (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
|
|
-- Business columns
|
|
-- ...
|
|
|
|
-- Audit columns
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ -- Soft delete
|
|
);
|
|
|
|
-- Standard indexes
|
|
CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id);
|
|
CREATE INDEX idx_{table}_deleted_at ON {schema}.{table}(deleted_at);
|
|
```
|
|
|
|
### 2. Row-Level Security Policies
|
|
|
|
#### Enable RLS
|
|
|
|
```sql
|
|
ALTER TABLE {schema}.{table} ENABLE ROW LEVEL SECURITY;
|
|
```
|
|
|
|
#### Standard Policy
|
|
|
|
```sql
|
|
-- Policy: tenant_isolation
|
|
-- Applies to: SELECT, INSERT, UPDATE, DELETE
|
|
CREATE POLICY tenant_isolation ON {schema}.{table}
|
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
```
|
|
|
|
**What this does:**
|
|
- `USING` clause filters SELECT queries
|
|
- Also applies to UPDATE and DELETE
|
|
- INSERT requires tenant_id to match
|
|
|
|
#### Super Admin Bypass (Optional)
|
|
|
|
```sql
|
|
-- Allow super admins to see all data
|
|
CREATE POLICY admin_bypass ON {schema}.{table}
|
|
USING (
|
|
current_setting('app.current_tenant_id')::uuid = tenant_id
|
|
OR
|
|
current_setting('app.is_super_admin', true)::boolean = true
|
|
);
|
|
```
|
|
|
|
#### Example: Full RLS Setup
|
|
|
|
```sql
|
|
-- Create table
|
|
CREATE TABLE products.products (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
sku VARCHAR(100),
|
|
price NUMERIC(12, 2),
|
|
created_by UUID REFERENCES auth.users(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_products_tenant_id ON products.products(tenant_id);
|
|
CREATE INDEX idx_products_sku ON products.products(tenant_id, sku) WHERE deleted_at IS NULL;
|
|
|
|
-- Enable RLS
|
|
ALTER TABLE products.products ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Create policy
|
|
CREATE POLICY tenant_isolation ON products.products
|
|
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
|
|
-- Grant permissions
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON products.products TO erp_app_user;
|
|
```
|
|
|
|
### 3. Backend Implementation
|
|
|
|
#### Middleware: Set Tenant Context
|
|
|
|
```typescript
|
|
// middleware/tenant-context.middleware.ts
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import pool from '../config/database';
|
|
|
|
export const setTenantContext = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const tenantId = req.user?.tenantId; // From JWT
|
|
|
|
if (!tenantId) {
|
|
return res.status(401).json({ error: 'Tenant not found' });
|
|
}
|
|
|
|
try {
|
|
// Set tenant context for this connection
|
|
await pool.query(
|
|
`SET LOCAL app.current_tenant_id = $1`,
|
|
[tenantId]
|
|
);
|
|
|
|
// Optional: Set super admin flag
|
|
if (req.user?.role === 'super_admin') {
|
|
await pool.query(`SET LOCAL app.is_super_admin = true`);
|
|
}
|
|
|
|
next();
|
|
} catch (error) {
|
|
console.error('Error setting tenant context:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
};
|
|
```
|
|
|
|
**Apply to all routes:**
|
|
|
|
```typescript
|
|
// app.ts
|
|
import express from 'express';
|
|
import { authenticateJWT } from './middleware/auth.middleware';
|
|
import { setTenantContext } from './middleware/tenant-context.middleware';
|
|
|
|
const app = express();
|
|
|
|
// Apply to all routes after authentication
|
|
app.use(authenticateJWT);
|
|
app.use(setTenantContext);
|
|
|
|
// Now all routes are tenant-scoped
|
|
app.use('/api/products', productRoutes);
|
|
```
|
|
|
|
#### BaseService with Tenant Enforcement
|
|
|
|
```typescript
|
|
// shared/services/base.service.ts
|
|
export abstract class BaseService<T, CreateDto, UpdateDto> {
|
|
constructor(
|
|
protected tableName: string,
|
|
protected schema: string
|
|
) {}
|
|
|
|
async findAll(
|
|
tenantId: string,
|
|
filters?: Filters,
|
|
pagination?: Pagination
|
|
): Promise<PaginatedResult<T>> {
|
|
const { page = 1, limit = 20 } = pagination || {};
|
|
const offset = (page - 1) * limit;
|
|
|
|
// RLS automatically filters by tenant_id
|
|
// No need to add WHERE tenant_id = $1
|
|
const query = `
|
|
SELECT *
|
|
FROM ${this.schema}.${this.tableName}
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT $1 OFFSET $2
|
|
`;
|
|
|
|
const result = await pool.query(query, [limit, offset]);
|
|
|
|
return {
|
|
data: result.rows,
|
|
total: result.rowCount,
|
|
page,
|
|
limit
|
|
};
|
|
}
|
|
|
|
async create(
|
|
data: CreateDto,
|
|
tenantId: string,
|
|
userId: string
|
|
): Promise<T> {
|
|
const columns = Object.keys(data);
|
|
const values = Object.values(data);
|
|
|
|
// Explicitly add tenant_id
|
|
columns.push('tenant_id', 'created_by');
|
|
values.push(tenantId, userId);
|
|
|
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
|
|
const query = `
|
|
INSERT INTO ${this.schema}.${this.tableName} (${columns.join(', ')})
|
|
VALUES (${placeholders})
|
|
RETURNING *
|
|
`;
|
|
|
|
const result = await pool.query(query, values);
|
|
return result.rows[0];
|
|
}
|
|
|
|
// Other CRUD methods...
|
|
}
|
|
```
|
|
|
|
### 4. Frontend Implementation
|
|
|
|
#### Store Tenant Info in Auth State
|
|
|
|
```typescript
|
|
// stores/auth.store.ts
|
|
import create from 'zustand';
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
tenantId: string;
|
|
tenantName: string;
|
|
role: string;
|
|
}
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
token: string | null;
|
|
login: (email: string, password: string) => Promise<void>;
|
|
logout: () => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>((set) => ({
|
|
user: null,
|
|
token: null,
|
|
|
|
login: async (email, password) => {
|
|
const response = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
|
|
const { user, token } = await response.json();
|
|
|
|
set({ user, token });
|
|
localStorage.setItem('token', token);
|
|
},
|
|
|
|
logout: () => {
|
|
set({ user: null, token: null });
|
|
localStorage.removeItem('token');
|
|
}
|
|
}));
|
|
```
|
|
|
|
#### Display Tenant Context
|
|
|
|
```tsx
|
|
// components/TenantIndicator.tsx
|
|
import { useAuthStore } from '../stores/auth.store';
|
|
|
|
export const TenantIndicator = () => {
|
|
const user = useAuthStore((state) => state.user);
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div className="bg-blue-100 px-4 py-2 rounded">
|
|
<span className="text-sm text-gray-700">
|
|
Tenant: <strong>{user.tenantName}</strong>
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
## Multi-Tenant SaaS Features
|
|
|
|
### Onboarding New Tenant
|
|
|
|
```typescript
|
|
// services/tenant.service.ts
|
|
export class TenantService {
|
|
async createTenant(data: CreateTenantDto): Promise<Tenant> {
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// 1. Create tenant
|
|
const tenantQuery = `
|
|
INSERT INTO auth.tenants (name, slug, plan)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING *
|
|
`;
|
|
const tenantResult = await client.query(tenantQuery, [
|
|
data.name,
|
|
data.slug,
|
|
data.plan || 'free'
|
|
]);
|
|
const tenant = tenantResult.rows[0];
|
|
|
|
// 2. Create admin user for tenant
|
|
const passwordHash = await bcrypt.hash(data.adminPassword, 10);
|
|
const userQuery = `
|
|
INSERT INTO auth.users (tenant_id, email, password_hash, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *
|
|
`;
|
|
await client.query(userQuery, [
|
|
tenant.id,
|
|
data.adminEmail,
|
|
passwordHash,
|
|
'admin'
|
|
]);
|
|
|
|
// 3. Initialize default data (catalogs, etc.)
|
|
await this.initializeTenantData(client, tenant.id);
|
|
|
|
await client.query('COMMIT');
|
|
return tenant;
|
|
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
private async initializeTenantData(client: PoolClient, tenantId: string) {
|
|
// Create default categories
|
|
await client.query(`
|
|
INSERT INTO products.categories (tenant_id, name)
|
|
VALUES
|
|
($1, 'General'),
|
|
($1, 'Services')
|
|
`, [tenantId]);
|
|
|
|
// Create default warehouse
|
|
await client.query(`
|
|
INSERT INTO inventory.warehouses (tenant_id, name, code)
|
|
VALUES ($1, 'Main Warehouse', 'WH01')
|
|
`, [tenantId]);
|
|
|
|
// More defaults...
|
|
}
|
|
}
|
|
```
|
|
|
|
### Tenant Switching (for super admins)
|
|
|
|
```typescript
|
|
// middleware/switch-tenant.middleware.ts
|
|
export const switchTenant = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const targetTenantId = req.headers['x-tenant-id'] as string;
|
|
|
|
if (req.user?.role !== 'super_admin') {
|
|
return res.status(403).json({ error: 'Only super admins can switch tenants' });
|
|
}
|
|
|
|
if (!targetTenantId) {
|
|
return res.status(400).json({ error: 'Target tenant ID required' });
|
|
}
|
|
|
|
// Override tenant context
|
|
await pool.query(`SET LOCAL app.current_tenant_id = $1`, [targetTenantId]);
|
|
next();
|
|
};
|
|
```
|
|
|
|
## Data Isolation Testing
|
|
|
|
### Test Suite
|
|
|
|
```typescript
|
|
// __tests__/multi-tenancy.test.ts
|
|
import { pool } from '../config/database';
|
|
import { TenantService } from '../services/tenant.service';
|
|
import { ProductService } from '../modules/products/product.service';
|
|
|
|
describe('Multi-Tenancy Data Isolation', () => {
|
|
let tenantA: Tenant;
|
|
let tenantB: Tenant;
|
|
|
|
beforeAll(async () => {
|
|
tenantA = await TenantService.createTenant({ name: 'Tenant A', ... });
|
|
tenantB = await TenantService.createTenant({ name: 'Tenant B', ... });
|
|
});
|
|
|
|
it('should isolate data between tenants', async () => {
|
|
// Set context to Tenant A
|
|
await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]);
|
|
|
|
// Create product for Tenant A
|
|
const productA = await ProductService.create({
|
|
name: 'Product A',
|
|
price: 100
|
|
}, tenantA.id, 'user-a');
|
|
|
|
// Switch context to Tenant B
|
|
await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]);
|
|
|
|
// Try to fetch products (should only see Tenant B's data)
|
|
const products = await ProductService.findAll(tenantB.id);
|
|
|
|
expect(products.data).toHaveLength(0); // Tenant B has no products
|
|
expect(products.data).not.toContainEqual(productA);
|
|
});
|
|
|
|
it('should prevent cross-tenant access', async () => {
|
|
// Set context to Tenant A
|
|
await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantA.id]);
|
|
|
|
// Create product for Tenant A
|
|
const productA = await ProductService.create({
|
|
name: 'Product A'
|
|
}, tenantA.id, 'user-a');
|
|
|
|
// Switch to Tenant B
|
|
await pool.query(`SET LOCAL app.current_tenant_id = $1`, [tenantB.id]);
|
|
|
|
// Try to access Tenant A's product
|
|
const result = await ProductService.findById(productA.id, tenantB.id);
|
|
|
|
expect(result).toBeNull(); // RLS blocks access
|
|
});
|
|
});
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Indexing Strategy
|
|
|
|
**Always index tenant_id:**
|
|
|
|
```sql
|
|
CREATE INDEX idx_{table}_tenant_id ON {schema}.{table}(tenant_id);
|
|
```
|
|
|
|
**Composite indexes for common queries:**
|
|
|
|
```sql
|
|
-- Query: Find product by SKU for tenant
|
|
CREATE INDEX idx_products_tenant_sku
|
|
ON products.products(tenant_id, sku)
|
|
WHERE deleted_at IS NULL;
|
|
|
|
-- Query: List recent orders for tenant
|
|
CREATE INDEX idx_orders_tenant_created
|
|
ON sales.orders(tenant_id, created_at DESC);
|
|
```
|
|
|
|
### Query Performance
|
|
|
|
**With RLS:**
|
|
```sql
|
|
EXPLAIN ANALYZE
|
|
SELECT * FROM products.products WHERE sku = 'ABC123';
|
|
|
|
-- Plan:
|
|
-- Index Scan using idx_products_tenant_sku
|
|
-- Filter: (tenant_id = '...'::uuid) ← Automatic
|
|
```
|
|
|
|
**RLS overhead:** ~5-10% (minimal with proper indexing)
|
|
|
|
### Connection Pooling
|
|
|
|
```typescript
|
|
// config/database.ts
|
|
import { Pool } from 'pg';
|
|
|
|
export const pool = new Pool({
|
|
host: process.env.DB_HOST,
|
|
port: parseInt(process.env.DB_PORT || '5432'),
|
|
database: process.env.DB_NAME,
|
|
user: process.env.DB_USER,
|
|
password: process.env.DB_PASSWORD,
|
|
max: 20, // Max connections
|
|
idleTimeoutMillis: 30000,
|
|
connectionTimeoutMillis: 2000,
|
|
});
|
|
|
|
// Set tenant context on each query
|
|
pool.on('connect', async (client) => {
|
|
// This is set per-request, not per-connection
|
|
// Use middleware instead
|
|
});
|
|
```
|
|
|
|
## Security Best Practices
|
|
|
|
### 1. Always Validate Tenant Access
|
|
|
|
```typescript
|
|
// Even with RLS, validate user belongs to tenant
|
|
if (req.user.tenantId !== req.params.tenantId) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
```
|
|
|
|
### 2. Never Disable RLS
|
|
|
|
```sql
|
|
-- ❌ NEVER DO THIS
|
|
ALTER TABLE products.products NO FORCE ROW LEVEL SECURITY;
|
|
```
|
|
|
|
### 3. Audit Tenant Changes
|
|
|
|
```sql
|
|
CREATE TABLE audit.tenant_changes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL,
|
|
user_id UUID NOT NULL,
|
|
action VARCHAR(50) NOT NULL,
|
|
table_name VARCHAR(100) NOT NULL,
|
|
record_id UUID,
|
|
old_data JSONB,
|
|
new_data JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
### 4. Monitor Cross-Tenant Attempts
|
|
|
|
```typescript
|
|
// Log suspicious activity
|
|
if (attemptedCrossTenantAccess) {
|
|
logger.warn('Cross-tenant access attempt', {
|
|
userId: req.user.id,
|
|
userTenant: req.user.tenantId,
|
|
targetTenant: req.params.tenantId,
|
|
ip: req.ip
|
|
});
|
|
}
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### RLS Not Working
|
|
|
|
**Check if RLS is enabled:**
|
|
```sql
|
|
SELECT tablename, rowsecurity
|
|
FROM pg_tables
|
|
WHERE schemaname = 'products';
|
|
```
|
|
|
|
**Check policies:**
|
|
```sql
|
|
SELECT * FROM pg_policies WHERE tablename = 'products';
|
|
```
|
|
|
|
**Verify tenant context is set:**
|
|
```sql
|
|
SHOW app.current_tenant_id;
|
|
```
|
|
|
|
### Performance Issues
|
|
|
|
**Check query plan:**
|
|
```sql
|
|
EXPLAIN ANALYZE
|
|
SELECT * FROM products.products WHERE name LIKE '%widget%';
|
|
```
|
|
|
|
**Add missing indexes:**
|
|
```sql
|
|
CREATE INDEX idx_products_name ON products.products(tenant_id, name);
|
|
```
|
|
|
|
## References
|
|
|
|
- [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
|
|
- [Architecture Documentation](./ARCHITECTURE.md)
|
|
- [Database Schema](../apps/erp-core/database/ddl/)
|