@loomwork/compliance 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +87 -2
- package/dist/SubjectIndex.d.ts +48 -0
- package/dist/SubjectIndex.d.ts.map +1 -0
- package/dist/SubjectIndex.js +90 -0
- package/dist/SubjectIndex.js.map +1 -0
- package/dist/eraseSubject.d.ts +540 -0
- package/dist/eraseSubject.d.ts.map +1 -0
- package/dist/eraseSubject.js +1294 -0
- package/dist/eraseSubject.js.map +1 -0
- package/dist/exportSubject.d.ts +164 -0
- package/dist/exportSubject.d.ts.map +1 -0
- package/dist/exportSubject.js +143 -0
- package/dist/exportSubject.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -3
- package/src/SubjectIndex.ts +132 -0
- package/src/eraseSubject.ts +1780 -0
- package/src/exportSubject.ts +326 -0
- package/src/index.ts +36 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
// ── T3 — the subject index (data-subject rights) ──────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// GDPR Art. 15/20 (access, portability) and Art. 17 (erasure) all start with one question:
|
|
6
|
+
// "which records concern this person?" Event-sourcing answers it structurally — every fact
|
|
7
|
+
// is in the log — but you still need the person→records mapping. This index maintains it as
|
|
8
|
+
// a fold over the event stream: `subjectId → { executionIds }`, plus the O(1) reverse.
|
|
9
|
+
//
|
|
10
|
+
// The kernel supplies no notion of "data subject" (G3 — that is policy). The app names the
|
|
11
|
+
// subject via a `SubjectResolver`: given an event, return the subject it concerns (usually
|
|
12
|
+
// read off EXECUTION_STARTED input, e.g. the userId), or `undefined` for events that name
|
|
13
|
+
// no subject. The resolver runs once per (execution, first-resolved) — the mapping is
|
|
14
|
+
// per-execution stable, so later events for a known execution are O(1) skips.
|
|
15
|
+
//
|
|
16
|
+
// T3-R2.4 — multi-tenant isolation. In a shared runtime serving several tenants (K12), two
|
|
17
|
+
// tenants can legitimately use the SAME subject id ("alice" at tenant A ≠ "alice" at tenant
|
|
18
|
+
// B). Merging them would leak one tenant's records into another's access/erasure request —
|
|
19
|
+
// a data-protection breach. So the index is PARTITIONED BY TENANT: the app supplies an
|
|
20
|
+
// optional `TenantResolver` (the tenant is not in the event by default — principal chains
|
|
21
|
+
// are not persisted — so the app reads it from input/metadata), and every query is scoped to
|
|
22
|
+
// a tenant. With no resolver, all records fall in one default partition (single-tenant apps
|
|
23
|
+
// are unchanged).
|
|
24
|
+
|
|
25
|
+
/** Given an event, the data subject it concerns — or `undefined` if it names none. */
|
|
26
|
+
export type SubjectResolver = (event: EventEnvelope) => string | undefined;
|
|
27
|
+
|
|
28
|
+
/** Given an event, the tenant it belongs to — or `undefined` for the default partition. */
|
|
29
|
+
export type TenantResolver = (event: EventEnvelope) => string | undefined;
|
|
30
|
+
|
|
31
|
+
/** The partition subjects fall into when no tenant is resolved (single-tenant apps). */
|
|
32
|
+
export const DEFAULT_TENANT = "";
|
|
33
|
+
|
|
34
|
+
export interface SubjectIndexOptions {
|
|
35
|
+
/** Partition the index by tenant (T3-R2.4). Omit for a single-tenant app. */
|
|
36
|
+
tenantOf?: TenantResolver;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A live, rebuildable index of `(tenant, subjectId) → executionIds`. Feed it the event stream
|
|
41
|
+
* (`handleEvent`, e.g. from `defineConfig`'s `onEvent`) or rebuild from a log
|
|
42
|
+
* ({@link SubjectIndex.fromEvents}) — both yield the same state (P2). Backed by a nested
|
|
43
|
+
* `Map<tenant, Map<subject, Set<execution>>>` for O(1) scoped membership + a reverse
|
|
44
|
+
* `Map<execution, {tenant, subject}>` for O(1) dedupe and lookup, so a burst of events for
|
|
45
|
+
* one execution is not re-resolved and tenants never share a subject bucket.
|
|
46
|
+
*/
|
|
47
|
+
export class SubjectIndex {
|
|
48
|
+
private readonly byTenant = new Map<string, Map<string, Set<string>>>();
|
|
49
|
+
private readonly byExecution = new Map<string, { tenant: string; subject: string }>();
|
|
50
|
+
private readonly tenantOf: TenantResolver;
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly subjectOf: SubjectResolver,
|
|
54
|
+
options: SubjectIndexOptions = {}
|
|
55
|
+
) {
|
|
56
|
+
this.tenantOf = options.tenantOf ?? (() => DEFAULT_TENANT);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Fold a full event array into a fresh index (deterministic rebuild from the log). */
|
|
60
|
+
static fromEvents(
|
|
61
|
+
events: readonly EventEnvelope[],
|
|
62
|
+
subjectOf: SubjectResolver,
|
|
63
|
+
options: SubjectIndexOptions = {}
|
|
64
|
+
): SubjectIndex {
|
|
65
|
+
const index = new SubjectIndex(subjectOf, options);
|
|
66
|
+
for (const event of events) index.handleEvent(event);
|
|
67
|
+
return index;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Record the subject (and tenant) an event concerns. Idempotent per execution (O(1) once mapped). */
|
|
71
|
+
handleEvent(event: EventEnvelope): void {
|
|
72
|
+
// Already mapped this execution — the (tenant, subject) is stable, nothing to do.
|
|
73
|
+
if (this.byExecution.has(event.executionId)) return;
|
|
74
|
+
const subject = this.subjectOf(event);
|
|
75
|
+
if (subject === undefined) return;
|
|
76
|
+
const tenant = this.tenantOf(event) ?? DEFAULT_TENANT;
|
|
77
|
+
this.byExecution.set(event.executionId, { tenant, subject });
|
|
78
|
+
let subjects = this.byTenant.get(tenant);
|
|
79
|
+
if (subjects === undefined) {
|
|
80
|
+
subjects = new Map();
|
|
81
|
+
this.byTenant.set(tenant, subjects);
|
|
82
|
+
}
|
|
83
|
+
let set = subjects.get(subject);
|
|
84
|
+
if (set === undefined) {
|
|
85
|
+
set = new Set();
|
|
86
|
+
subjects.set(subject, set);
|
|
87
|
+
}
|
|
88
|
+
set.add(event.executionId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The executions concerning a subject WITHIN a tenant (empty if unknown). Sorted for
|
|
93
|
+
* deterministic output. `tenant` defaults to the single partition (single-tenant apps).
|
|
94
|
+
*/
|
|
95
|
+
executionsOf(subjectId: string, tenant: string = DEFAULT_TENANT): string[] {
|
|
96
|
+
const set = this.byTenant.get(tenant)?.get(subjectId);
|
|
97
|
+
return set === undefined ? [] : [...set].sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The subject an execution concerns, or `undefined` if none was resolved. */
|
|
101
|
+
subjectOfExecution(executionId: string): string | undefined {
|
|
102
|
+
return this.byExecution.get(executionId)?.subject;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The tenant an execution belongs to, or `undefined` if the execution is unknown. */
|
|
106
|
+
tenantOfExecution(executionId: string): string | undefined {
|
|
107
|
+
return this.byExecution.get(executionId)?.tenant;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether the index has any records for a subject within a tenant. */
|
|
111
|
+
has(subjectId: string, tenant: string = DEFAULT_TENANT): boolean {
|
|
112
|
+
return this.byTenant.get(tenant)?.has(subjectId) ?? false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The subjects within a tenant (sorted). The inventory backbone for a compliance report. */
|
|
116
|
+
subjects(tenant: string = DEFAULT_TENANT): string[] {
|
|
117
|
+
const subjects = this.byTenant.get(tenant);
|
|
118
|
+
return subjects === undefined ? [] : [...subjects.keys()].sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** All tenant ids with any records (sorted). A single-tenant app reports one, `""`. */
|
|
122
|
+
tenants(): string[] {
|
|
123
|
+
return [...this.byTenant.keys()].sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Number of distinct (tenant, subject) pairs indexed. */
|
|
127
|
+
get size(): number {
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (const subjects of this.byTenant.values()) total += subjects.size;
|
|
130
|
+
return total;
|
|
131
|
+
}
|
|
132
|
+
}
|