# ARC-003 — Tenant Isolation Standard **Status:** Accepted **Datum:** 2026-06-01 **Beslutsfattare:** AAMOS Architecture Team --- ## Kontext AAMOS driftar ekonomidata för flera separata juridiska och organisatoriska entiteter. Läckage av data mellan tenants är ett **kritiskt fel** — dels juridiskt (bokföringssekretess, GDPR), dels affärsmässigt (förtroende). Ett enstaka cross-tenant-dataläckage kan vara fatal för produkten. Idag implementeras isolering uteslutande på applikationsnivå. Detta är nödvändigt men inte tillräckligt som enda försvarslinje. Standardet definierar **defense in depth** för alla lager. Ledger Engine kör idag på `:3250` med `tenant_id` på varje rad — korrekt ansats, men utan formaliserat kontrakt. Hermes Event Fabric har `tenant_id` i envelope — korrekt. Detta dokument formaliserar dessa val och utökar dem till hela stacken. --- ## Beslut ### 1. Tenant-definition och hierarki #### Vad är en tenant? En **tenant** är den primära isoleringsdomänen i AAMOS. Den representerar en oberoende ekonomisk och juridisk enhet vars data **aldrig** ska vara tillgänglig för en annan tenant, oavsett konfiguration. ``` tenant_id (UUID v7) ├── Primär nyckel i alla tabeller ├── Korresponderar mot en juridisk/operativ entitet ├── Är oföränderlig efter skapande └── Kan INTE delegeras nedåt i hierarkin ``` #### Hierarki ``` Tenant (isoleringsdomän — hårdgräns) └── Organization (organisatorisk enhet inom tenant — ARC-001) ├── org_type = "company" (juridisk person) ├── org_type = "department" (avdelning) ├── org_type = "team" (team) └── org_type = "subsidiary" (dotterbolag inom samma tenant) ``` **Viktigt:** - Dotterbolag inom samma juridiska koncern → **samma tenant**, separerade via `Organization.parent_id` - Separata juridiska entiteter med oberoende bokföring → **separata tenants** - En person (Person.id) kan existera i **multipla tenants** — men med separata Person-rader per tenant #### Tenant-livscykel ``` provisioning → active → suspended → terminated ``` - `terminated`-tenants: data locked, 90 dagars cooling period, sedan arkivering eller borttagning per avtal - Suspended-tenants: läsåtkomst tillåten för ägare, inga skrivoperationer --- ### 2. Dataklassificering — 4 nivåer | Nivå | Etikett | Definition | Kryptering i vila | Kryptering i transit | |------|---------|------------|-------------------|----------------------| | 0 | `public` | Kan delas fritt utan risk | Ej krav | TLS | | 1 | `internal` | Intern information, ej känslig | Rekommenderat | TLS | | 2 | `confidential` | Affärskritisk, skyddad | **Obligatoriskt** | TLS + mTLS rekommenderat | | 3 | `restricted` | Personuppgifter, juridisk risk | **Obligatoriskt + field-level** | TLS + mTLS | #### Ekonomimodulens dataklassificering | Dataobjekt | Nivå | Motivering | |------------|------|------------| | Kontoplan (struktur) | `internal` | Strukturinformation, ej känslig | | Transaktioner (belopp, konto) | `confidential` | Affärskritisk bokföring | | Leverantörsfakturor | `confidential` | Affärskritisk | | Bankkopplingar, bankuppgifter | `restricted` | Finansiell känslig info | | Personuppgifter (Person.national_id) | `restricted` | GDPR Art. 9 | | Lönedata | `restricted` | GDPR + arbetsrättslig känslighet | | Kundsaldo, kreditgräns | `confidential` | Affärskritisk | | Revisionslogg (audit trail) | `confidential` | Skyddad men inte personuppgift | | Inloggningsuppgifter | `restricted` | Autentiseringsdata | --- ### 3. Isolering per lager #### 3.1 PostgreSQL — Row-Level Security (RLS) **Princip:** Databas ska vara sista försvarslinjen. Även om applikationen har en bugg ska databasen neka cross-tenant-åtkomst. **Implementation:** ```sql -- Aktivera RLS på alla tabeller med tenant_id ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE transactions FORCE ROW LEVEL SECURITY; -- Gäller även superuser -- Policy: läsning CREATE POLICY tenant_isolation_select ON transactions FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Policy: insert CREATE POLICY tenant_isolation_insert ON transactions FOR INSERT WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Policy: update CREATE POLICY tenant_isolation_update ON transactions FOR UPDATE USING (tenant_id = current_setting('app.current_tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Policy: delete (inkl. soft-delete) CREATE POLICY tenant_isolation_delete ON transactions FOR DELETE USING (tenant_id = current_setting('app.current_tenant_id')::uuid); ``` **Sätta tenant-kontext i applikationen (connection-level):** ```javascript // I middleware, FÖRE varje databasanrop async function setTenantContext(client, tenantId) { // Validera UUID-format if (!isValidUUID(tenantId)) { throw new SecurityError('INVALID_TENANT_ID', tenantId); } await client.query( `SELECT set_config('app.current_tenant_id', $1, true)`, [tenantId] ); } // Express middleware app.use(async (req, res, next) => { const tenantId = req.headers['x-tenant-id'] || req.user?.tenant_id; if (!tenantId) return res.status(401).json({ error: 'TENANT_ID_REQUIRED' }); req.db = await pool.connect(); await setTenantContext(req.db, tenantId); res.on('finish', () => req.db.release()); next(); }); ``` **RLS-aktivering för befintliga tabeller (Ekonomimodulen):** ```sql -- Skapa en funktion för att enablea RLS på alla tabeller i ett schema CREATE OR REPLACE FUNCTION enable_rls_for_schema(schema_name text) RETURNS void AS $$ DECLARE tbl text; BEGIN FOR tbl IN SELECT tablename FROM pg_tables WHERE schemaname = schema_name AND tablename NOT IN ('schema_migrations', 'capability_registry') LOOP EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', schema_name, tbl); EXECUTE format('ALTER TABLE %I.%I FORCE ROW LEVEL SECURITY', schema_name, tbl); EXECUTE format( 'CREATE POLICY IF NOT EXISTS tenant_isolation ON %I.%I USING (tenant_id = current_setting(''app.current_tenant_id'')::uuid)', schema_name, tbl ); END LOOP; END; $$ LANGUAGE plpgsql; -- Kör för ekonomi-schema: SELECT enable_rls_for_schema('ekonomi'); ``` **Undantag från RLS:** Tabeller utan `tenant_id` (globala konfigurationstabeller) skyddas via separat `is_global = true`-policy och `pg_roles`-baserad åtkomststyrning. --- #### 3.2 Redis — Key-prefix schema **Obligatoriskt nyckelformat:** ``` {tenant_id}:{service}:{data_type}:{identifier} ``` **Exempel:** ``` # Korrekt f47ac10b-58cc-4372-a567-0e02b2c3d479:ledger:session:user_123 f47ac10b-58cc-4372-a567-0e02b2c3d479:ledger:cache:accounts f47ac10b-58cc-4372-a567-0e02b2c3d479:hermes:queue:pending # FEL — saknar tenant-prefix ledger:cache:accounts session:user_123 ``` **Implementation i Node.js:** ```javascript class TenantRedisClient { constructor(redisClient, tenantId) { this.client = redisClient; this.tenantId = tenantId; this.validateTenantId(tenantId); } key(service, dataType, identifier) { return `${this.tenantId}:${service}:${dataType}:${identifier}`; } async get(service, dataType, identifier) { return this.client.get(this.key(service, dataType, identifier)); } async set(service, dataType, identifier, value, ttl) { const k = this.key(service, dataType, identifier); if (ttl) return this.client.setex(k, ttl, value); return this.client.set(k, value); } // Scan aldrig utan tenant-prefix — blockerat async scan(pattern) { const safePattern = `${this.tenantId}:${pattern}`; return this.client.scan(0, 'MATCH', safePattern, 'COUNT', 100); } validateTenantId(id) { if (!/^[0-9a-f-]{36}$/i.test(id)) { throw new SecurityError('INVALID_TENANT_ID_FORMAT'); } } } ``` **Redis-isolering vid multi-tenant-miljö (production):** Överväg separata Redis-databaser (`SELECT 0..15`) eller separata Redis-instanser per tenant-grupp för stark isolering om tenant-volym tillåter det. --- #### 3.3 Hermes Event Fabric — Tenant-envelope **Event-envelope (redan implementerat, formaliseras här):** ```json { "envelope": { "event_id": "uuid-v7", "tenant_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "event_type": "aamos.ekonomi.transaction.posted", "event_version": "1.0.0", "source_service": "ledger-engine", "correlation_id": "uuid", "causation_id": "uuid|null", "published_at": "2026-06-01T19:00:00Z" }, "payload": { ... } } ``` **Enforcement-regler:** 1. **Subscription-filtrering:** Varje konsument prenumererar på `{tenant_id}:*` — aldrig på `*` utan prefix 2. **Consumer-validering:** Konsumenten MÅSTE validera `envelope.tenant_id` mot sin egen kontext vid mottagande 3. **Ingen cross-tenant-routing:** Event-router får aldrig leverera ett event till en konsument med annan `tenant_id` 4. **JSONL-fallback:** Filer namnges `{tenant_id}/{date}/{service}.jsonl` — aldrig mixade tenant-filer ```javascript // Hermes consumer-mönster hermes.subscribe(`${tenantId}:aamos.ekonomi.#`, async (message) => { // Alltid validera — defense in depth if (message.envelope.tenant_id !== tenantId) { await auditLog.critical('CROSS_TENANT_EVENT_RECEIVED', { expected: tenantId, received: message.envelope.tenant_id, event_id: message.envelope.event_id }); throw new SecurityError('CROSS_TENANT_EVENT'); } // Process event... }); ``` --- #### 3.4 Search / Vector — Namespacing för framtida semantic search **Princip för framtida implementation:** ``` Namespace-format: {tenant_id}_{collection} Exempel: f47ac10b_transactions, f47ac10b_documents Sökning MÅSTE alltid inkludera tenant_id-filter: { "filter": { "must": [{ "key": "tenant_id", "match": { "value": "{tenant_id}" } }] }, "query_vector": [...] } ``` **Krav vid val av vector store:** - Stöd för metadata-filtrering FÖRE nearest-neighbor-sökning (pre-filter, ej post-filter) - Namespacing på collection-nivå som alternativ - Audit-loggning av alla vektorsökningar med tenant-kontext --- #### 3.5 AI-kontext och GDPR — Regler för prompt-innehåll **Konkreta regler för vad som får finnas i en LLM-prompt:** | Datatyp | Tillåtet i prompt? | Krav om tillåtet | |---------|-------------------|------------------| | Transaktionsbelopp (aggregerat) | ✅ Ja | Utan individuell koppling | | Kontonummer (ej bank) | ✅ Ja | Internt kontonummer | | Leverantörsnamn | ✅ Ja | | | Person.name | ⚠️ Begränsat | Bara om nödvändigt + pseudonymiserat | | Person.national_id | ❌ Nej | Aldrig | | Person.email, phone | ❌ Nej | Aldrig i raw-form | | Bankkonto-/kortnummer | ❌ Nej | Aldrig | | Lönedata (individnivå) | ❌ Nej | Aldrig | | Transaktioner (individnivå, med namn) | ⚠️ Begränsat | Kräver pseudonymisering | | Audit-loggar med user_id | ⚠️ Begränsat | Pseudonymisera user_id | **Obligatorisk pseudonymisering:** ```javascript function pseudonymizeForPrompt(data, tenantId) { const piiFields = ['national_id', 'email', 'phone', 'bank_account']; return mapDeep(data, (key, value) => { if (piiFields.includes(key)) { // Deterministisk pseudonymisering: kan reverseras av ägartenant, ej av AI-lager return `[REDACTED:${hashForTenant(value, tenantId).slice(0, 8)}]`; } return value; }); } ``` **LLM-provider-krav:** - Ingen träning på kunddata (kräver `training: false` i API-avtal eller zero-data-retention) - EU-baserad databehandling för `restricted`-data (GDPR Art. 44) - Promptloggar lagras max 30 dagar och klassas som `confidential` --- ### 4. Cross-tenant-skydd #### 4.1 Vad händer vid felaktig tenant_id **Applikationslager:** ``` 1. Middleware validerar tenant_id-format (UUID v4/v7) 2. Middleware verifierar att tenant existerar och är active 3. Middleware verifierar att inloggad användare tillhör tenanten 4. Om något steg misslyckas: 403 Forbidden (aldrig 404 — avslöjar ej existens) ``` **Databaslager (RLS):** ``` Om app-lager missar och fel tenant_id når DB: → RLS returnerar 0 rader för SELECT → RLS returnerar 0 rows affected för UPDATE/DELETE → RLS kastar constraint-fel för INSERT med fel tenant_id ``` **Observabilitet:** Varje 403-svar med `TENANT_MISMATCH` loggas med: - Begärd tenant_id - Autentiserad users tenant_id - Request path + method - IP-adress - Timestamp #### 4.2 Audit-trail för cross-tenant-försök ```sql CREATE TABLE security_audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type text NOT NULL, -- 'CROSS_TENANT_ATTEMPT', 'RLS_VIOLATION', etc. severity text NOT NULL, -- 'warning', 'critical' tenant_id UUID, -- Begärd tenant (kan vara ogiltig) actor_id UUID, -- Autentiserad principal resource text, -- Vad man försökte nå details jsonb, -- Full kontext ip_address inet, created_at timestamptz DEFAULT now() ); -- Index för snabb sökning per tenant CREATE INDEX security_audit_tenant_idx ON security_audit_log (tenant_id, created_at DESC); CREATE INDEX security_audit_type_idx ON security_audit_log (event_type, created_at DESC); ``` **Alerting-regler:** - `CROSS_TENANT_ATTEMPT`: alert inom 5 minuter (Slack + email till security) - 3+ CROSS_TENANT_ATTEMPT från samma IP inom 10 minuter: automatisk IP-block + PagerDuty - RLS-violation (data nådde DB-lagret felaktigt): omedelbar incident --- ### 5. Enforcement — nuläge och väg framåt #### 5.1 Applikationsnivå (dagens approach) ``` Request → Auth middleware → Tenant validation → RLS context set → Business logic → DB ↑ ↑ ↑ Lager 1: Lager 2: Lager 3: JWT-validering Tenant-existens app.current_tenant_id + user-tenant + user-membership = security context association check för RLS ``` **Middleware-ordning (Express):** ```javascript app.use(parseJWT); // 1. Parse + verify JWT app.use(extractTenantId); // 2. tenant_id från JWT claims app.use(validateTenantActive); // 3. Kontrollera tenant-status app.use(assertUserBelongsToTenant); // 4. User ↔ tenant-koppling app.use(setDatabaseTenantContext); // 5. Sätt RLS-kontext ``` #### 5.2 DB-level RLS (nästa steg) **Implementationsplan:** ```sql -- Steg 1: Skapa app-roll med begränsad åtkomst CREATE ROLE aamos_app LOGIN PASSWORD '...'; GRANT CONNECT ON DATABASE aamos TO aamos_app; GRANT USAGE ON SCHEMA ekonomi TO aamos_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ekonomi TO aamos_app; -- OBS: aamos_app har INTE BYPASS RLS — RLS gäller fullt ut -- Steg 2: Skapa admin-roll (för migrationer) som är undantagen RLS CREATE ROLE aamos_admin LOGIN PASSWORD '...'; ALTER ROLE aamos_admin BYPASSRLS; -- Bara för schema-migrationer -- Steg 3: Verifiera att aamos_app inte kan se cross-tenant data SET ROLE aamos_app; SELECT set_config('app.current_tenant_id', 'tenant-a-uuid', true); -- SELECT FROM transactions WHERE tenant_id = 'tenant-b-uuid' → 0 rows (RLS) ``` **Verifieringstest (körs i CI/CD):** ```sql -- Automatiserat test: cross-tenant-åtkomst ska returnera 0 rader DO $$ DECLARE row_count integer; BEGIN PERFORM set_config('app.current_tenant_id', 'aaaaaaaa-0000-0000-0000-000000000000', true); SELECT COUNT(*) INTO row_count FROM ekonomi.transactions WHERE tenant_id = 'bbbbbbbb-0000-0000-0000-000000000000'; ASSERT row_count = 0, 'RLS FAILURE: Cross-tenant data visible!'; END; $$; ``` --- ### 6. Tenant-provisioning-spec ```javascript async function provisionTenant(tenantSpec) { const tenantId = uuidv7(); await db.transaction(async (trx) => { // 1. Skapa tenant-rad await trx.insert('tenants', { id: tenantId, name: tenantSpec.name, status: 'active', plan: tenantSpec.plan, created_at: new Date() }); // 2. Skapa root Organization await trx.insert('organizations', { id: uuidv7(), tenant_id: tenantId, name: tenantSpec.name, org_type: 'company', status: 'active' }); // 3. Initiera Redis-namespace (warm up) await redis.set(`${tenantId}:meta:provisioned_at`, Date.now()); // 4. Publicera provisioning-event på Hermes await hermes.publish(`${tenantId}:aamos.platform.tenant.provisioned`, { envelope: { tenant_id: tenantId, event_type: 'aamos.platform.tenant.provisioned' }, payload: { tenant_id: tenantId, name: tenantSpec.name } }); }); return tenantId; } ``` --- ## Konsekvenser ### Positiva - **Defense in depth:** Tre oberoende lager (app, RLS, event-envelope) måste alla misslyckas simultant för ett läckage - **Revision-ready:** Fullständig audit-trail för alla cross-tenant-försök - **GDPR-kompabilitet:** Explicit dataklassificering och AI-prompt-regler gör GDPR-redovisning möjlig - **Skalbarhet:** Key-prefix-schema och RLS skalas utan arkitekturförändring ### Negativa / risker - **RLS-overhead:** `set_config` per anrop kostar ~0.1ms — acceptabelt, men bör mätas vid hög load - **Migrationsrisk:** Befintliga tabeller utan `tenant_id` kräver datamigration - **Connection pooling:** `set_config` med `is_local=true` är transaction-scoped. Med connection pools MÅSTE tenant-kontext sättas om vid varje transaktion, ej bara vid connection-acquire - **Testdisciplin:** CI/CD-tester måste inkludera cross-tenant-assertioner --- ## Implementation ### Fas 1 — Omedelbart (Ekonomimodulen) 1. Verifiera att alla tabeller har `tenant_id NOT NULL` 2. Lägg till Express-middleware-kedjan (5 steg ovan) 3. Skapa `security_audit_log`-tabell 4. Aktivera RLS på alla ekonomitabeller ### Fas 2 — Nästa sprint 1. Automatiserade cross-tenant-tester i CI 2. Redis TenantRedisClient-wrapper 3. Hermes consumer-validering formaliserad 4. Alerting för CROSS_TENANT_ATTEMPT ### Fas 3 — Inför CRM-modul 1. Dela tenant-infrastruktur mellan moduler (gemensamt `tenants`-schema) 2. Tenant-provisioning-API 3. Vector search namespacing implementerat 4. AI-prompt-pseudonymisering i capability-lager (ARC-002) --- ## Öppna frågor 1. **Multi-tenant vs single-tenant hosting per kund?** Nuvarande arkitektur: shared DB med RLS. Vid enterprise-kunder kan dedikerad DB-instans per tenant vara krav. 2. **Tenant-merge (företagsförvärv)?** Hur hanteras sammanslagning av två tenants? Kräver separata migrationsstrategi och kryptonyckel-hantering. 3. **Krypteringsnycklar per tenant?** `restricted`-data bör krypteras med tenant-specifika nycklar. Key Management Service (KMS) behöver definieras. 4. **Data residency?** Vid EU-expansion: måste tenant-data garanteras stanna inom specifik AWS-region. Kräver taggning per tenant.