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:
2026-09-15 02:40:52 +02:00
committed by admin
parent 12080fc517
commit 17bd1b40a0
4 changed files with 116 additions and 3 deletions
+19
View File
@@ -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
+49 -2
View File
@@ -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");
+19
View File
@@ -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;
+29 -1
View File
@@ -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"));