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

19 KiB

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:

-- 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):

// 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):

-- 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:

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):

{
  "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
// 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...
});

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:

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

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):

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:

-- 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):

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

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.