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 |
|
||||
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
|
||||
|
||||
@@ -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<string, PiiPolicy> = 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<PiiIdentityKey> {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user