- 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
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:
- Subscription-filtrering: Varje konsument prenumererar på
{tenant_id}:*— aldrig på*utan prefix - Consumer-validering: Konsumenten MÅSTE validera
envelope.tenant_idmot sin egen kontext vid mottagande - Ingen cross-tenant-routing: Event-router får aldrig leverera ett event till en konsument med annan
tenant_id - 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...
});
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:
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: falsei 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_configper anrop kostar ~0.1ms — acceptabelt, men bör mätas vid hög load - Migrationsrisk: Befintliga tabeller utan
tenant_idkräver datamigration - Connection pooling:
set_configmedis_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)
- Verifiera att alla tabeller har
tenant_id NOT NULL - Lägg till Express-middleware-kedjan (5 steg ovan)
- Skapa
security_audit_log-tabell - Aktivera RLS på alla ekonomitabeller
Fas 2 — Nästa sprint
- Automatiserade cross-tenant-tester i CI
- Redis TenantRedisClient-wrapper
- Hermes consumer-validering formaliserad
- Alerting för CROSS_TENANT_ATTEMPT
Fas 3 — Inför CRM-modul
- Dela tenant-infrastruktur mellan moduler (gemensamt
tenants-schema) - Tenant-provisioning-API
- Vector search namespacing implementerat
- AI-prompt-pseudonymisering i capability-lager (ARC-002)
Öppna frågor
-
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. -
Tenant-merge (företagsförvärv)?
Hur hanteras sammanslagning av två tenants? Kräver separata migrationsstrategi och kryptonyckel-hantering. -
Krypteringsnycklar per tenant?
restricted-data bör krypteras med tenant-specifika nycklar. Key Management Service (KMS) behöver definieras. -
Data residency?
Vid EU-expansion: måste tenant-data garanteras stanna inom specifik AWS-region. Kräver taggning per tenant.