The Invernaderos API implements a fully isolated multi-tenant architecture where each tenant (company/farm) has complete data separation across all system layers: MQTT topics, database schemas, and cache keys.
Implementation Date: November 2025 (Migrations V3-V10)The multi-tenant system uses BIGINT auto-generated IDs (not UUIDs) with denormalized tenant_id fields for optimized queries.
Topic Pattern: GREENHOUSE/{tenantId}Examples:- GREENHOUSE/SARA → tenant_id for "Vivero Sara" company- GREENHOUSE/001 → tenant_id for generic tenant- GREENHOUSE/NARANJOS → tenant_id for "Los Naranjos" farm
The tenantId is extracted from the topic path and used to isolate all data operations.
Topic: GREENHOUSE (no tenant suffix)Maps to: tenant_id for "DEFAULT" tenantUse case: Backward compatibility during migration period
The legacy GREENHOUSE topic is maintained for backward compatibility only. All new integrations should use the multi-tenant format GREENHOUSE/{tenantId}.
The metadata.tenants table serves as the master tenant registry:
V3__add_tenant_company_fields.sql
ALTER TABLE metadata.tenants ADD COLUMN IF NOT EXISTS company_name VARCHAR(200), ADD COLUMN IF NOT EXISTS tax_id VARCHAR(50), ADD COLUMN IF NOT EXISTS address TEXT, ADD COLUMN IF NOT EXISTS city VARCHAR(100), ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20), ADD COLUMN IF NOT EXISTS province VARCHAR(100), ADD COLUMN IF NOT EXISTS country VARCHAR(50) DEFAULT 'España', ADD COLUMN IF NOT EXISTS phone VARCHAR(50), ADD COLUMN IF NOT EXISTS contact_person VARCHAR(150), ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), ADD COLUMN IF NOT EXISTS mqtt_topic_prefix VARCHAR(50), ADD COLUMN IF NOT EXISTS coordinates JSONB, ADD COLUMN IF NOT EXISTS notes TEXT;-- Tax ID must be unique (CIF/NIF)ALTER TABLE metadata.tenants ADD CONSTRAINT uq_tenants_tax_id UNIQUE (tax_id);-- MQTT topic prefix must be unique (used for routing)ALTER TABLE metadata.tenants ADD CONSTRAINT uq_tenants_mqtt_topic_prefix UNIQUE (mqtt_topic_prefix);
ALTER TABLE metadata.greenhouses ADD COLUMN IF NOT EXISTS greenhouse_code VARCHAR(50), ADD COLUMN IF NOT EXISTS mqtt_topic VARCHAR(100), ADD COLUMN IF NOT EXISTS mqtt_publish_interval_seconds INT DEFAULT 5, ADD COLUMN IF NOT EXISTS external_id VARCHAR(100);-- Add explicit FK to tenants (CASCADE DELETE)ALTER TABLE metadata.greenhouses ADD CONSTRAINT fk_greenhouse_tenant FOREIGN KEY (tenant_id) REFERENCES metadata.tenants(id) ON DELETE CASCADE;-- greenhouse_code must be unique within a tenantALTER TABLE metadata.greenhouses ADD CONSTRAINT uq_greenhouse_code_per_tenant UNIQUE (tenant_id, greenhouse_code);-- mqtt_topic must be globally uniqueALTER TABLE metadata.greenhouses ADD CONSTRAINT uq_greenhouse_mqtt_topic UNIQUE (mqtt_topic);
Sensors include a denormalized tenant_id field for query performance:
V5__add_sensor_mqtt_fields.sql
ALTER TABLE metadata.sensors ADD COLUMN IF NOT EXISTS sensor_code VARCHAR(50), ADD COLUMN IF NOT EXISTS tenant_id BIGINT, -- Denormalized for performance ADD COLUMN IF NOT EXISTS mqtt_field_name VARCHAR(100), ADD COLUMN IF NOT EXISTS data_format VARCHAR(20) DEFAULT 'NUMERIC';-- Populate tenant_id based on greenhouse_id (denormalization)UPDATE metadata.sensors sSET tenant_id = ( SELECT g.tenant_id FROM metadata.greenhouses g WHERE g.id = s.greenhouse_id)WHERE s.tenant_id IS NULL;-- Add FK to tenants (for direct queries by tenant)ALTER TABLE metadata.sensors ADD CONSTRAINT fk_sensor_tenant FOREIGN KEY (tenant_id) REFERENCES metadata.tenants(id) ON DELETE CASCADE;
Why Denormalization?The tenant_id field is stored directly in the sensors table (even though it’s derivable from greenhouses.tenant_id) to avoid expensive JOINs in multi-tenant queries. This is a performance optimization for time-series queries that filter by tenant.
@Entity@Table(name = "sensor_readings", schema = "iot")@IdClass(SensorReadingId::class)data class SensorReading( @Id @Column(nullable = false) val time: Instant, @Id @Column(name = "sensor_id", nullable = false, length = 50) val sensorId: String, @Column(name = "greenhouse_id", nullable = false) val greenhouseId: Long, // BIGINT auto-generated ID @Column(name = "tenant_id") val tenantId: Long? = null, // Denormalized tenant ID @Column(name = "sensor_type", nullable = false, length = 30) val sensorType: String, @Column(nullable = false, columnDefinition = "double precision") val value: Double, @Column(length = 20) val unit: String? = null)
Data Type Change: The greenhouse_id and tenant_id fields use BIGINT (Kotlin Long), not UUIDs. This was changed from the original UUID design for better performance with auto-generated IDs.
Repository queries filter by tenant to ensure data isolation:
GreenhouseRepository.kt
@Repositoryinterface GreenhouseRepository : JpaRepository<Greenhouse, Long> { /** * Find greenhouses by tenant ID. */ fun findByTenantId(tenantId: Long): List<Greenhouse> /** * Find active greenhouses for a specific tenant. * CRITICAL for multi-tenant validation in MQTT processing. */ fun findByTenantIdAndIsActive(tenantId: Long, isActive: Boolean): List<Greenhouse> /** * Find greenhouse by name within a tenant. */ fun findByTenantIdAndName(tenantId: Long, name: String): Greenhouse? /** * Find a greenhouse by ID and tenant ID (double-check tenant isolation). */ fun findByIdAndTenantId(id: Long, tenantId: Long): Greenhouse?}
TimescaleDB queries include tenant filtering using native SQL:
SensorReadingRepository.kt
/** * Calculate the trend (trend) of a sensor in a time period. * Uses TimescaleDB FIRST() and LAST() functions. * * Optimized: Avoids loading all data in memory, calculates directly in DB. * * @param sensorId ID of the sensor * @param tenantId ID of the tenant (null to filter only by sensor) * @param startTime Start of period * @param endTime End of period * @return Map with: first_value, last_value, first_time, last_time, unit */@Query(value = """ SELECT FIRST(value, time) AS first_value, LAST(value, time) AS last_value, FIRST(time, time) AS first_time, LAST(time, time) AS last_time, MAX(unit) AS unit FROM iot.sensor_readings WHERE sensor_id = :sensorId AND (:tenantId IS NULL OR tenant_id = :tenantId) AND time BETWEEN :startTime AND :endTime""", nativeQuery = true)fun calculateTrend( @Param("sensorId") sensorId: String, @Param("tenantId") tenantId: Long?, // NULL for cross-tenant queries @Param("startTime") startTime: Instant, @Param("endTime") endTime: Instant): Map<String, Any>?
-- Update greenhousesUPDATE metadata.greenhousesSET tenant_id = (SELECT id FROM metadata.tenants WHERE mqtt_topic_prefix = 'DEFAULT')WHERE tenant_id IS NULL;-- Update sensors (denormalization)UPDATE metadata.sensors sSET tenant_id = ( SELECT g.tenant_id FROM metadata.greenhouses g WHERE g.id = s.greenhouse_id)WHERE s.tenant_id IS NULL;-- Update sensor_readings (time-series)UPDATE iot.sensor_readings srSET tenant_id = ( SELECT g.tenant_id FROM metadata.greenhouses g WHERE g.id = sr.greenhouse_id)WHERE sr.tenant_id IS NULL;
Step 3: Enable Constraints
-- Make tenant_id NOT NULL after migrationALTER TABLE metadata.greenhouses ALTER COLUMN tenant_id SET NOT NULL;ALTER TABLE metadata.sensors ALTER COLUMN tenant_id SET NOT NULL;
Run these migrations during a maintenance window. The UPDATE on iot.sensor_readings can take several minutes for large datasets (millions of rows).
Testing Multi-Tenant Isolation
Test 1: Verify Topic Routing
# Publish to tenant SARAmosquitto_pub -t "GREENHOUSE/SARA" -m '{"TEMPERATURA INVERNADERO 01": 25.5}'# Publish to tenant NARANJOSmosquitto_pub -t "GREENHOUSE/NARANJOS" -m '{"TEMPERATURA INVERNADERO 01": 22.0}'# Query database - should see separate tenant_id valuesSELECT tenant_id, COUNT(*) FROM iot.sensor_readingsGROUP BY tenant_id;
Test 2: Cross-Tenant Query Prevention
// Attempting to access another tenant's data should failval saraTenant = tenantRepository.findByMqttTopicPrefix("SARA")val naranjosGreenhouse = greenhouseRepository.findByIdAndTenantId( id = naranjosGreenhouseId, tenantId = saraTenant.id // Wrong tenant!)// Result: null (isolation enforced)