Files
boc/docs/arc/ARC-003-tenant-isolation-standard.md
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

567 lines
19 KiB
Markdown

# 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.