From 17bd1b40a02ea454112f63b13c01993120df3736 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 31 Aug 2026 01:18:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(0pt):=20record=20legitimate-interest=20sta?= =?UTF-8?q?nce=20=E2=80=94=20B2B=20contacts,=20company-linked=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded decision (Simon 2026-08-31): personal data may be stored under legitimate interest ONLY as business contacts bound to a company/org entity. Gate enforces it: legitimate_interest requires purpose=business_contact and entity_linked_only=true; recordStored() requires entity_id (orgnr/rgs/LEI, hashed) and refuses person-only rows. +1 test; suite 167 pass | 1 skip; typecheck clean. --- docs/compliance/PII_GUARDRAIL.md | 19 ++++++++++++ src/services/pii-gate-service.ts | 51 ++++++++++++++++++++++++++++++-- src/types/pii.ts | 19 ++++++++++++ tests/p0/pii-gate.test.ts | 30 ++++++++++++++++++- 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/docs/compliance/PII_GUARDRAIL.md b/docs/compliance/PII_GUARDRAIL.md index 78c82df..36ef93f 100644 --- a/docs/compliance/PII_GUARDRAIL.md +++ b/docs/compliance/PII_GUARDRAIL.md @@ -16,6 +16,23 @@ first byte is stored**. (`0pt.pii_policy_created | pii_data_retained | pii_erased | pii_retention_purged`). +## Recorded legitimate-interest stance (Simon, 2026-08-31) + +> "Vi får lagra dem om dem är kopplade till företagen i affärssyfte." + +Personal data may be stored under **legitimate interest (art. 6(1)(f))** ONLY +as **business contacts bound to a company/org entity** (`entity_linked_only`). +The gate enforces this mechanically: + +- `legal_basis: legitimate_interest` ⇒ `purpose` MUST be `business_contact` and + `entity_linked_only` MUST be `true`. A lazily-written policy is rejected. +- `recordStored()` refuses person-only rows for such policies: an `entity_id` + (orgnr/rgs-nummer/LEI) is REQUIRED; only its hash is ever stored. +- Stand-alone personal records (no backing company) are not permitted under LI. + +This is the recorded business-policy decision — a written LIA should be kept +and its id referenced in `basis_ref` (`lia-YYYY-MM-b2b-NN` naming is fine). + ## GDPR article mapping | Requirement | Implementation | @@ -35,6 +52,8 @@ first byte is stored**. - [x] Write the **Legitimate Interest Assessment** (or record consent / contract / legal obligation) and store its id in `basis_ref`. LIA minimum: purpose, necessity, individuals' rights impact, mitigations. + For LI: purpose `business_contact` + `entity_linked_only` (see recorded + stance above — an LIA referencing `lia-YYYY-MM-b2b-NN` is good practice). - [x] **VERIFY current terms + robots.txt + availability** on the live site. Do not hardcode "catsit.com allows" — re-check each enable. - [x] Set `robots_and_rate_confirmed: true` + a `SourceRatePolicy` matching diff --git a/src/services/pii-gate-service.ts b/src/services/pii-gate-service.ts index da84d99..c86cb96 100644 --- a/src/services/pii-gate-service.ts +++ b/src/services/pii-gate-service.ts @@ -22,6 +22,7 @@ import type { PiiIdentityKey, PiiErasureRequest, PiiAccessStatement, + ProcessingPurpose, } from "../types/pii"; import type { Source } from "../types/source"; import { createAud0Client, type Aud0Client, type Aud0Event } from "../clients/aud0-client"; @@ -44,6 +45,7 @@ export class PiiLegalBasisError extends Error {} export class PiiMinimisationError extends Error {} export class PiiRetentionError extends Error {} export class PiiRobotsRateError extends Error {} +export class PiiEntityLinkageError extends Error {} export class PiiGateService { private policies: Map = new Map(); @@ -80,6 +82,21 @@ export class PiiGateService { throw new Error("field_allowlist must contain at least one field (data minimisation)"); } + // Recorded decision (Simon, 2026-08-31): legitimate interest is accepted for + // BUSINESS CONTACTS — personal data only in correspondence with a company/org + // entity. Enforce that posture so "B2B-kontakt, företagskopplad" cannot be + // bypassed by a lazily-written policy. + const purpose: ProcessingPurpose = input.purpose ?? "business_contact"; + const entityLinkedOnly = input.entity_linked_only ?? input.legal_basis === "legitimate_interest"; + if (input.legal_basis === "legitimate_interest") { + if (purpose !== "business_contact") { + throw new Error("legitimate_interest is only accepted with purpose=business_contact (recorded decision 2026-08-31)"); + } + if (!entityLinkedOnly) { + throw new Error("legitimate_interest requires entity_linked_only=true — personal data must be bound to a company/org entity"); + } + } + const now = new Date().toISOString(); const policy: PiiPolicy = { source_id: input.source_id, @@ -87,6 +104,8 @@ export class PiiGateService { project_id: input.project_id, touches_personal_data: true, legal_basis: input.legal_basis, + purpose, + entity_linked_only: entityLinkedOnly, basis_ref: input.basis_ref, retention_days: input.retention_days, field_allowlist: [...input.field_allowlist], @@ -108,7 +127,13 @@ export class PiiGateService { resource_type: "source_pii_policy", resource_id: input.source_id, risk_level: "L3", - payload: { legal_basis: input.legal_basis, retention_days: input.retention_days, field_count: input.field_allowlist.length }, + payload: { + legal_basis: input.legal_basis, + purpose, + entity_linked_only: entityLinkedOnly, + retention_days: input.retention_days, + field_count: input.field_allowlist.length, + }, }); return policy; } @@ -192,14 +217,26 @@ export class PiiGateService { subjectId: string; field: string; artifactRef?: string; + /** Company/org entity the value is bound to (orgnr/rgs/LEI). REQUIRED when the policy is entity_linked_only. */ + entityId?: string; }): Promise { await this.assertCanStore(input.source, [input.field], input.tenantId); const policy = this.policies.get(input.source.source_id)!; + if (policy.entity_linked_only && !input.entityId) { + throw new PiiEntityLinkageError( + DECISION_TEMPLATE.replace("%s", input.source.source_id).replace( + "%s", + "policy requires entity linkage (business contact, recorded decision 2026-08-31) — pass entityId (orgnr/rgs/LEI); personal data may only be stored in correspondence with a company/org entity", + ), + ); + } + const now = Date.now(); const key: PiiIdentityKey = { key_hash: hash256(`${sourceIdSalt(input.source.source_id)}:${input.identityKey}`), subject_hash: hash256(input.subjectId), + entity_hash: input.entityId ? hash256(normaliseEntity(input.entityId)) : null, source_id: input.source.source_id, tenant_id: input.tenantId, field: input.field, @@ -220,7 +257,12 @@ export class PiiGateService { resource_type: "pii_identity_key", resource_id: key.key_hash, risk_level: "L3", - payload: { source_id: input.source.source_id, field: input.field, expires_at: key.expires_at }, + payload: { + source_id: input.source.source_id, + field: input.field, + expires_at: key.expires_at, + entity_linked: key.entity_hash !== null, + }, }); return key; } @@ -318,6 +360,11 @@ function sourceIdSalt(sourceId: string): string { return `0pt-pii-v1:${sourceId}`; } +/** Normalise an org/rgs nummer or LEI to canonical digits/letters before hashing. */ +function normaliseEntity(entityId: string): string { + return entityId.replace(/[^0-9A-Za-z]/g, "").toUpperCase(); +} + /** sha256 hex digest — never the plaintext. */ export function hash256(value: string): string { return createHash("sha256").update(value, "utf8").digest("hex"); diff --git a/src/types/pii.ts b/src/types/pii.ts index 606b07b..952a7be 100644 --- a/src/types/pii.ts +++ b/src/types/pii.ts @@ -16,6 +16,14 @@ /** GDPR art. 6(1) legal bases 0pt accepts for personal-data sources. */ export type LegalBasis = "legitimate_interest" | "consent" | "contract" | "legal_obligation"; +/** + * The purpose of processing. SIAX's recorded decision (Simon, 2026-08-31): + * personal data may be stored when LEGITIMATE INTEREST applies for business + * contacts — i.e. bound to a company/org entity, for business purposes. + * Other bases are allowed but every policy records its purpose. + */ +export type ProcessingPurpose = "business_contact" | "consent_marketing" | "contract_performance" | "legal_obligation"; + /** Compliance posture for one source that touches personal data. */ export interface PiiPolicy { source_id: string; @@ -23,6 +31,13 @@ export interface PiiPolicy { project_id: string; touches_personal_data: true; legal_basis: LegalBasis; + purpose: ProcessingPurpose; + /** + * REQUIRED under legitimate_interest (business_contact): personal data may + * only be stored in correspondence with a company/org entity (org/rgs-nr, + * GLEIF/LEI). recordStored() rejects stray person-only rows. + */ + entity_linked_only: boolean; /** * Pointer to the evidence: a recorded Legitimate Interest Assessment * (LIA) document id, the consent record id, the contract clause, or the @@ -64,6 +79,8 @@ export interface CreatePiiPolicyInput { basis_ref: string; retention_days: number; field_allowlist: string[]; + purpose?: ProcessingPurpose; + entity_linked_only?: boolean; pseudonymize_keys?: boolean; robots_and_rate_confirmed?: boolean; created_by?: string; @@ -79,6 +96,8 @@ export interface CreatePiiPolicyInput { export interface PiiIdentityKey { key_hash: string; subject_hash: string; + /** Company/org entity the value is bound to (orgnr/rgs/LEI), hashed. */ + entity_hash: string | null; source_id: string; tenant_id: string; field: string; diff --git a/tests/p0/pii-gate.test.ts b/tests/p0/pii-gate.test.ts index 2ffbe0c..0d1cfbf 100644 --- a/tests/p0/pii-gate.test.ts +++ b/tests/p0/pii-gate.test.ts @@ -53,6 +53,34 @@ describe("PiiGateService — GDPR guardrail (fail-closed)", () => { ).rejects.toThrow(/data minimisation/); }); + it("enforces the recorded decision: legitimate_interest = B2B business contact, company-linked only", async () => { + const gate = new PiiGateService(); + await expect( + gate.createPiiPolicy({ source_id: "s_li_wrong", tenant_id: "t1", project_id: "p1", legal_basis: "legitimate_interest", basis_ref: "lia-2026-001", retention_days: 90, field_allowlist: ["email"], purpose: "consent_marketing" }), + ).rejects.toThrowError(/only accepted with purpose=business_contact/); + await expect( + gate.createPiiPolicy({ source_id: "s_li_wrong2", tenant_id: "t1", project_id: "p1", legal_basis: "legitimate_interest", basis_ref: "lia-2026-001", retention_days: 90, field_allowlist: ["email"], entity_linked_only: false }), + ).rejects.toThrowError(/entity_linked_only=true/); + + await gate.createPiiPolicy({ + source_id: "s_li_ok", tenant_id: "t1", project_id: "p1", + legal_basis: "legitimate_interest", basis_ref: "lia-2026-b2b-01", + retention_days: 365, field_allowlist: ["email", "phone"], + }); + const p = await gate.getPiiPolicy("s_li_ok"); + expect(p?.purpose).toBe("business_contact"); + expect(p?.entity_linked_only).toBe(true); + + // Business contact must be bound to a company — stray person-only rows are refused. + const liSource = { ...company, source_id: "s_li_ok" }; + await expect( + gate.recordStored({ source: liSource, tenantId: "t1", identityKey: "k", subjectId: "s", field: "email" }), + ).rejects.toThrowError(/entity linkage/); + const linked = await gate.recordStored({ source: liSource, tenantId: "t1", identityKey: "k", subjectId: "s", field: "email", entityId: "556677-8899" }); + expect(linked.entity_hash).toMatch(/^[0-9a-f]{64}$/); + expect(linked.entity_hash).not.toContain("556677"); + }); + it("blocks out-of-allowlist fields (minimisation) and unconfirmed web sources", async () => { const gate = new PiiGateService(); await gate.createPiiPolicy({ @@ -117,7 +145,7 @@ describe("PiiGateService — GDPR guardrail (fail-closed)", () => { legal_basis: "legitimate_interest", basis_ref: "lia-2026-002", retention_days: 30, field_allowlist: ["street_address"], robots_and_rate_confirmed: true, }); - await gate.recordStored({ source: ratsit, tenantId: "t1", identityKey: "k-1", subjectId: "s-1", field: "street_address" }); + await gate.recordStored({ source: ratsit, tenantId: "t1", identityKey: "k-1", subjectId: "s-1", field: "street_address", entityId: "5566778899" }); vi.setSystemTime(new Date("2026-09-15T00:00:00Z")); const { purged } = await gate.enforceRetention(new Date("2026-09-15T00:00:00Z"));