feat(0pt): record legitimate-interest stance — B2B contacts, company-linked only
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.
This commit is contained in:
@@ -16,6 +16,23 @@ first byte is stored**.
|
|||||||
(`0pt.pii_policy_created | pii_data_retained | pii_erased |
|
(`0pt.pii_policy_created | pii_data_retained | pii_erased |
|
||||||
pii_retention_purged`).
|
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
|
## GDPR article mapping
|
||||||
|
|
||||||
| Requirement | Implementation |
|
| Requirement | Implementation |
|
||||||
@@ -35,6 +52,8 @@ first byte is stored**.
|
|||||||
- [x] Write the **Legitimate Interest Assessment** (or record consent /
|
- [x] Write the **Legitimate Interest Assessment** (or record consent /
|
||||||
contract / legal obligation) and store its id in `basis_ref`.
|
contract / legal obligation) and store its id in `basis_ref`.
|
||||||
LIA minimum: purpose, necessity, individuals' rights impact, mitigations.
|
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.
|
- [x] **VERIFY current terms + robots.txt + availability** on the live site.
|
||||||
Do not hardcode "catsit.com allows" — re-check each enable.
|
Do not hardcode "catsit.com allows" — re-check each enable.
|
||||||
- [x] Set `robots_and_rate_confirmed: true` + a `SourceRatePolicy` matching
|
- [x] Set `robots_and_rate_confirmed: true` + a `SourceRatePolicy` matching
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
PiiIdentityKey,
|
PiiIdentityKey,
|
||||||
PiiErasureRequest,
|
PiiErasureRequest,
|
||||||
PiiAccessStatement,
|
PiiAccessStatement,
|
||||||
|
ProcessingPurpose,
|
||||||
} from "../types/pii";
|
} from "../types/pii";
|
||||||
import type { Source } from "../types/source";
|
import type { Source } from "../types/source";
|
||||||
import { createAud0Client, type Aud0Client, type Aud0Event } from "../clients/aud0-client";
|
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 PiiMinimisationError extends Error {}
|
||||||
export class PiiRetentionError extends Error {}
|
export class PiiRetentionError extends Error {}
|
||||||
export class PiiRobotsRateError extends Error {}
|
export class PiiRobotsRateError extends Error {}
|
||||||
|
export class PiiEntityLinkageError extends Error {}
|
||||||
|
|
||||||
export class PiiGateService {
|
export class PiiGateService {
|
||||||
private policies: Map<string, PiiPolicy> = new Map();
|
private policies: Map<string, PiiPolicy> = new Map();
|
||||||
@@ -80,6 +82,21 @@ export class PiiGateService {
|
|||||||
throw new Error("field_allowlist must contain at least one field (data minimisation)");
|
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 now = new Date().toISOString();
|
||||||
const policy: PiiPolicy = {
|
const policy: PiiPolicy = {
|
||||||
source_id: input.source_id,
|
source_id: input.source_id,
|
||||||
@@ -87,6 +104,8 @@ export class PiiGateService {
|
|||||||
project_id: input.project_id,
|
project_id: input.project_id,
|
||||||
touches_personal_data: true,
|
touches_personal_data: true,
|
||||||
legal_basis: input.legal_basis,
|
legal_basis: input.legal_basis,
|
||||||
|
purpose,
|
||||||
|
entity_linked_only: entityLinkedOnly,
|
||||||
basis_ref: input.basis_ref,
|
basis_ref: input.basis_ref,
|
||||||
retention_days: input.retention_days,
|
retention_days: input.retention_days,
|
||||||
field_allowlist: [...input.field_allowlist],
|
field_allowlist: [...input.field_allowlist],
|
||||||
@@ -108,7 +127,13 @@ export class PiiGateService {
|
|||||||
resource_type: "source_pii_policy",
|
resource_type: "source_pii_policy",
|
||||||
resource_id: input.source_id,
|
resource_id: input.source_id,
|
||||||
risk_level: "L3",
|
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;
|
return policy;
|
||||||
}
|
}
|
||||||
@@ -192,14 +217,26 @@ export class PiiGateService {
|
|||||||
subjectId: string;
|
subjectId: string;
|
||||||
field: string;
|
field: string;
|
||||||
artifactRef?: 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<PiiIdentityKey> {
|
}): Promise<PiiIdentityKey> {
|
||||||
await this.assertCanStore(input.source, [input.field], input.tenantId);
|
await this.assertCanStore(input.source, [input.field], input.tenantId);
|
||||||
const policy = this.policies.get(input.source.source_id)!;
|
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 now = Date.now();
|
||||||
const key: PiiIdentityKey = {
|
const key: PiiIdentityKey = {
|
||||||
key_hash: hash256(`${sourceIdSalt(input.source.source_id)}:${input.identityKey}`),
|
key_hash: hash256(`${sourceIdSalt(input.source.source_id)}:${input.identityKey}`),
|
||||||
subject_hash: hash256(input.subjectId),
|
subject_hash: hash256(input.subjectId),
|
||||||
|
entity_hash: input.entityId ? hash256(normaliseEntity(input.entityId)) : null,
|
||||||
source_id: input.source.source_id,
|
source_id: input.source.source_id,
|
||||||
tenant_id: input.tenantId,
|
tenant_id: input.tenantId,
|
||||||
field: input.field,
|
field: input.field,
|
||||||
@@ -220,7 +257,12 @@ export class PiiGateService {
|
|||||||
resource_type: "pii_identity_key",
|
resource_type: "pii_identity_key",
|
||||||
resource_id: key.key_hash,
|
resource_id: key.key_hash,
|
||||||
risk_level: "L3",
|
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;
|
return key;
|
||||||
}
|
}
|
||||||
@@ -318,6 +360,11 @@ function sourceIdSalt(sourceId: string): string {
|
|||||||
return `0pt-pii-v1:${sourceId}`;
|
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. */
|
/** sha256 hex digest — never the plaintext. */
|
||||||
export function hash256(value: string): string {
|
export function hash256(value: string): string {
|
||||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
/** GDPR art. 6(1) legal bases 0pt accepts for personal-data sources. */
|
/** GDPR art. 6(1) legal bases 0pt accepts for personal-data sources. */
|
||||||
export type LegalBasis = "legitimate_interest" | "consent" | "contract" | "legal_obligation";
|
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. */
|
/** Compliance posture for one source that touches personal data. */
|
||||||
export interface PiiPolicy {
|
export interface PiiPolicy {
|
||||||
source_id: string;
|
source_id: string;
|
||||||
@@ -23,6 +31,13 @@ export interface PiiPolicy {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
touches_personal_data: true;
|
touches_personal_data: true;
|
||||||
legal_basis: LegalBasis;
|
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
|
* Pointer to the evidence: a recorded Legitimate Interest Assessment
|
||||||
* (LIA) document id, the consent record id, the contract clause, or the
|
* (LIA) document id, the consent record id, the contract clause, or the
|
||||||
@@ -64,6 +79,8 @@ export interface CreatePiiPolicyInput {
|
|||||||
basis_ref: string;
|
basis_ref: string;
|
||||||
retention_days: number;
|
retention_days: number;
|
||||||
field_allowlist: string[];
|
field_allowlist: string[];
|
||||||
|
purpose?: ProcessingPurpose;
|
||||||
|
entity_linked_only?: boolean;
|
||||||
pseudonymize_keys?: boolean;
|
pseudonymize_keys?: boolean;
|
||||||
robots_and_rate_confirmed?: boolean;
|
robots_and_rate_confirmed?: boolean;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
@@ -79,6 +96,8 @@ export interface CreatePiiPolicyInput {
|
|||||||
export interface PiiIdentityKey {
|
export interface PiiIdentityKey {
|
||||||
key_hash: string;
|
key_hash: string;
|
||||||
subject_hash: string;
|
subject_hash: string;
|
||||||
|
/** Company/org entity the value is bound to (orgnr/rgs/LEI), hashed. */
|
||||||
|
entity_hash: string | null;
|
||||||
source_id: string;
|
source_id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
field: string;
|
field: string;
|
||||||
|
|||||||
@@ -53,6 +53,34 @@ describe("PiiGateService — GDPR guardrail (fail-closed)", () => {
|
|||||||
).rejects.toThrow(/data minimisation/);
|
).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 () => {
|
it("blocks out-of-allowlist fields (minimisation) and unconfirmed web sources", async () => {
|
||||||
const gate = new PiiGateService();
|
const gate = new PiiGateService();
|
||||||
await gate.createPiiPolicy({
|
await gate.createPiiPolicy({
|
||||||
@@ -117,7 +145,7 @@ describe("PiiGateService — GDPR guardrail (fail-closed)", () => {
|
|||||||
legal_basis: "legitimate_interest", basis_ref: "lia-2026-002",
|
legal_basis: "legitimate_interest", basis_ref: "lia-2026-002",
|
||||||
retention_days: 30, field_allowlist: ["street_address"], robots_and_rate_confirmed: true,
|
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"));
|
vi.setSystemTime(new Date("2026-09-15T00:00:00Z"));
|
||||||
const { purged } = await gate.enforceRetention(new Date("2026-09-15T00:00:00Z"));
|
const { purged } = await gate.enforceRetention(new Date("2026-09-15T00:00:00Z"));
|
||||||
|
|||||||
Reference in New Issue
Block a user