@kindgi/compliance 0.0.0-bootstrap.0 → 0.1.0
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 +201 -0
- package/README.md +87 -2
- package/dist/classifier.d.ts +65 -0
- package/dist/classifier.d.ts.map +1 -0
- package/dist/classifier.js +4 -0
- package/dist/classifier.js.map +1 -0
- package/dist/errors.d.ts +93 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +31 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/resolve-emit.d.ts +75 -0
- package/dist/resolve-emit.d.ts.map +1 -0
- package/dist/resolve-emit.js +181 -0
- package/dist/resolve-emit.js.map +1 -0
- package/dist/types.d.ts +307 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +51 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -4
- package/src/classifier.ts +69 -0
- package/src/errors.ts +111 -0
- package/src/generator.ts +69 -0
- package/src/index.ts +68 -0
- package/src/resolve-emit.ts +256 -0
- package/src/types.ts +382 -0
package/package.json
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/compliance",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kindgi™ compliance evidence contract. Types for the Evidence wire record and its sub-types (EvidenceActor, EvidenceSubject, EvidenceKind, EvidenceOutcome, EvidencePayload, EvidenceSignature, ProvenanceRef, ActorKind; ComplianceEvidence alias), the ComplianceProvider seam (EmitEvidenceInput, ListEvidenceFilter, EvidenceSigner), the ComplianceEvidenceGenerator interface with its inputs (RecordFromRunOptions, RunEvidenceContext, ModelCallSummary, ToolInvocationSummary, GuardrailResultSummary), EvidenceFilter, EvidencePage, SignedEvidenceBundle and EvidenceBundleBody, the classifier file format (ComplianceClassifierFile, Classification, LoadedClassifier), and the ComplianceError union. Runtime exports: the EVIDENCE_KINDS list, the pure auditEventToEvidence transform, and emitResolveEvent / emitLifecycleEvent, which append env-* and secret-* audit events to an AuditEventBinding. The generator implementation and the classifier loader are provided by the Kindgi runtime or by the caller.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"repository": {
|
|
7
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kindgi/kindgi-sdk.git",
|
|
9
|
+
"directory": "packages/compliance"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kindgi/kindgi-sdk/tree/main/packages/compliance#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kindgi/kindgi-sdk/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@kindgi/audit-events": "0.1.0",
|
|
31
|
+
"@kindgi/platform": "0.1.0",
|
|
32
|
+
"@kindgi/types": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@kindgi/specs": "0.1.0",
|
|
36
|
+
"@types/node": "^22.10.5",
|
|
37
|
+
"typescript": "^5.7.3",
|
|
38
|
+
"vitest": "^2.1.8"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.0.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"provenance": true
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-kind classification decision. The retention + signing + export
|
|
6
|
+
* behavior for one event kind. Loaded from a classifier JSON file at
|
|
7
|
+
* deployment boot; consumed by retention cleanup and signed export.
|
|
8
|
+
*/
|
|
9
|
+
export interface Classification {
|
|
10
|
+
readonly retention: {
|
|
11
|
+
/**
|
|
12
|
+
* Baseline retention in days. Rows older than this are eligible
|
|
13
|
+
* for deletion UNLESS `legalHold` is set OR `onDenyDays` is set
|
|
14
|
+
* and applies.
|
|
15
|
+
*/
|
|
16
|
+
readonly days: number;
|
|
17
|
+
/**
|
|
18
|
+
* When set, denied/failed variants of this kind are retained
|
|
19
|
+
* longer than the baseline. Security investigations often need
|
|
20
|
+
* denials retained past the operational rows. Only meaningful
|
|
21
|
+
* for kinds that carry a deny signal (authz-decision:
|
|
22
|
+
* `allowed=false`).
|
|
23
|
+
*/
|
|
24
|
+
readonly onDenyDays?: number;
|
|
25
|
+
/**
|
|
26
|
+
* When true, retention cleanup NEVER deletes rows of this kind.
|
|
27
|
+
* Legal-hold semantics — deletion requires operator action past
|
|
28
|
+
* this classifier. Overrides `days`.
|
|
29
|
+
*/
|
|
30
|
+
readonly legalHold?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Kinds that get Ed25519-signed at export time when a regulator
|
|
34
|
+
* bundle is generated. Signatures are byte-exact over canonical
|
|
35
|
+
* event bytes; the classifier doesn't hold the key, only the
|
|
36
|
+
* decision to sign.
|
|
37
|
+
*/
|
|
38
|
+
readonly signed: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Kinds included in regulator-facing signed export bundles.
|
|
41
|
+
* Non-exportable kinds stay out of the bundle regardless of
|
|
42
|
+
* signature policy.
|
|
43
|
+
*/
|
|
44
|
+
readonly exportable: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wire schema for the classifier JSON file. Versioned envelope. */
|
|
48
|
+
export interface ComplianceClassifierFile {
|
|
49
|
+
readonly version: 1;
|
|
50
|
+
/** Default classification for kinds not explicitly listed. */
|
|
51
|
+
readonly default: Classification;
|
|
52
|
+
/** Per-kind overrides. Missing kind → `default`. */
|
|
53
|
+
readonly byKind: Readonly<Record<string, Classification>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Loaded classifier — same shape as the file, but the `resolve` helper
|
|
58
|
+
* returns the correct classification (per-kind or default) in one call.
|
|
59
|
+
*
|
|
60
|
+
* Built from a `ComplianceClassifierFile` by the runtime's loader or by
|
|
61
|
+
* the caller; deployments pass it to `@kindgi/api` as
|
|
62
|
+
* `CreateAppInput.complianceClassifier`. This package only defines the
|
|
63
|
+
* shape.
|
|
64
|
+
*/
|
|
65
|
+
export interface LoadedClassifier {
|
|
66
|
+
readonly file: ComplianceClassifierFile;
|
|
67
|
+
/** Return the classification for the given event kind. */
|
|
68
|
+
resolve(kind: string): Classification;
|
|
69
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { ProjectId, Result, SigningKeyId } from '@kindgi/types';
|
|
5
|
+
|
|
6
|
+
import type { Evidence } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Errors emitted by the compliance surface. Every variant carries a `code`
|
|
10
|
+
* for pattern matching; messages are human-readable, not API contract.
|
|
11
|
+
*/
|
|
12
|
+
export type ComplianceError =
|
|
13
|
+
| InvalidEvidenceError
|
|
14
|
+
| PersistenceError
|
|
15
|
+
| SignerFailureError
|
|
16
|
+
| SigningKeyMissingError
|
|
17
|
+
| SigningFailureError
|
|
18
|
+
| InvalidCursorError
|
|
19
|
+
| ProjectNotFoundError
|
|
20
|
+
| UnsupportedVersionError;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Structural validation of an evidence record against the compliance
|
|
24
|
+
* schema failed — e.g. `generator.recordFromRun` assembled a record that
|
|
25
|
+
* doesn't match the wire schema. A caller-shape bug or a schema drift,
|
|
26
|
+
* never a persistence issue.
|
|
27
|
+
*/
|
|
28
|
+
export interface InvalidEvidenceError {
|
|
29
|
+
readonly code: 'invalid-evidence';
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly issues: readonly { readonly path: string; readonly message: string }[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The caller-plugged persistence binding threw or returned an error.
|
|
36
|
+
*/
|
|
37
|
+
export interface PersistenceError {
|
|
38
|
+
readonly code: 'persistence-error';
|
|
39
|
+
readonly message: string;
|
|
40
|
+
readonly cause: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A caller-plugged `EvidenceSigner` function threw. Distinct from
|
|
45
|
+
* `SigningFailureError` (which is specific to the generator's own
|
|
46
|
+
* Ed25519 signing path, e.g. `exportSigned`) so callers can distinguish
|
|
47
|
+
* the two signing paths.
|
|
48
|
+
*/
|
|
49
|
+
export interface SignerFailureError {
|
|
50
|
+
readonly code: 'signer-failure';
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly cause: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The caller asked for a signed export but the requested `SigningKeyId`
|
|
57
|
+
* has no material in the mounted `SigningKeyBinding`. Typically a
|
|
58
|
+
* misconfiguration.
|
|
59
|
+
*/
|
|
60
|
+
export interface SigningKeyMissingError {
|
|
61
|
+
readonly code: 'signing-key-missing';
|
|
62
|
+
readonly message: string;
|
|
63
|
+
readonly signingKeyId: SigningKeyId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The Ed25519 signing primitive itself rejected the material — malformed
|
|
68
|
+
* key bytes, wrong length, or an underlying primitive that refused the
|
|
69
|
+
* input.
|
|
70
|
+
*/
|
|
71
|
+
export interface SigningFailureError {
|
|
72
|
+
readonly code: 'signing-failure';
|
|
73
|
+
readonly message: string;
|
|
74
|
+
readonly signingKeyId: SigningKeyId;
|
|
75
|
+
readonly cause: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* An evidence listing received a `cursor` that isn't decodable to a
|
|
80
|
+
* `(timestamp, id)` pair. Almost always a client bug.
|
|
81
|
+
*/
|
|
82
|
+
export interface InvalidCursorError {
|
|
83
|
+
readonly code: 'invalid-cursor';
|
|
84
|
+
readonly message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An evidence write (`recordFromRun`, `ComplianceProvider.emit`) named a
|
|
89
|
+
* `projectId` that doesn't exist inside the tenant. Distinct from
|
|
90
|
+
* `PersistenceError` so the API layer can map the caller-shape bug to a
|
|
91
|
+
* 404 wire response rather than 500.
|
|
92
|
+
*/
|
|
93
|
+
export interface ProjectNotFoundError {
|
|
94
|
+
readonly code: 'project-not-found';
|
|
95
|
+
readonly message: string;
|
|
96
|
+
readonly projectId: ProjectId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A record read from storage carried a `payload.version` newer than this
|
|
101
|
+
* reader knows how to normalize — typically during a rolling deploy.
|
|
102
|
+
*/
|
|
103
|
+
export interface UnsupportedVersionError {
|
|
104
|
+
readonly code: 'unsupported-version';
|
|
105
|
+
readonly message: string;
|
|
106
|
+
readonly version: number;
|
|
107
|
+
readonly evidenceId: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type EmitResult = Result<Evidence, ComplianceError>;
|
|
111
|
+
export type ListResult = Result<readonly Evidence[], ComplianceError>;
|
package/src/generator.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { AuditEvent } from '@kindgi/audit-events';
|
|
5
|
+
import type { ComplianceEvidenceId, Result, RunId, SigningKeyId, TenantId } from '@kindgi/types';
|
|
6
|
+
|
|
7
|
+
import type { ComplianceError } from './errors.js';
|
|
8
|
+
import type {
|
|
9
|
+
ComplianceEvidence,
|
|
10
|
+
EvidenceFilter,
|
|
11
|
+
EvidenceOutcome,
|
|
12
|
+
EvidencePayload,
|
|
13
|
+
RecordFromRunOptions,
|
|
14
|
+
SignedEvidenceBundle,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The runtime service that turns terminated runs into audit-event
|
|
19
|
+
* records and produces signed export bundles.
|
|
20
|
+
*
|
|
21
|
+
* Public interface — deployments plug an implementation into
|
|
22
|
+
* `@kindgi/api` via `CreateAppInput.complianceGenerator`. The Kindgi
|
|
23
|
+
* runtime provides one; callers may supply their own. Implementations
|
|
24
|
+
* may expose additional subscription / auto-emit methods; this interface
|
|
25
|
+
* is the minimum surface `@kindgi/api` consumes.
|
|
26
|
+
*/
|
|
27
|
+
export interface ComplianceEvidenceGenerator {
|
|
28
|
+
recordFromRun(
|
|
29
|
+
options: RecordFromRunOptions,
|
|
30
|
+
): Promise<Result<{ readonly evidenceId: ComplianceEvidenceId }, ComplianceError>>;
|
|
31
|
+
|
|
32
|
+
exportSigned(
|
|
33
|
+
tenantId: TenantId,
|
|
34
|
+
filter: EvidenceFilter,
|
|
35
|
+
signingKeyId: SigningKeyId,
|
|
36
|
+
): Promise<Result<SignedEvidenceBundle, ComplianceError>>;
|
|
37
|
+
|
|
38
|
+
describe(): { readonly name: string; readonly version: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Materialize a `ComplianceEvidence` wire record from an `AuditEvent`.
|
|
43
|
+
* The bundle body carries `ComplianceEvidence[]` for wire-shape
|
|
44
|
+
* stability — regulators verifying bundles read one record shape
|
|
45
|
+
* regardless of how events are stored.
|
|
46
|
+
*
|
|
47
|
+
* Pure function — no I/O, no random. Safe to call from any layer.
|
|
48
|
+
*/
|
|
49
|
+
export function auditEventToEvidence(event: AuditEvent): ComplianceEvidence {
|
|
50
|
+
return {
|
|
51
|
+
id: event.id as ComplianceEvidenceId,
|
|
52
|
+
tenantId: event.tenantId,
|
|
53
|
+
// Tenant-level events carry no project; the key is omitted, never `undefined`.
|
|
54
|
+
...(event.projectId !== undefined && { projectId: event.projectId }),
|
|
55
|
+
kind: event.kind,
|
|
56
|
+
timestamp: event.timestamp,
|
|
57
|
+
payload: extractDoc(event.payload) as EvidencePayload,
|
|
58
|
+
...(event.outcome !== undefined && { outcome: event.outcome as EvidenceOutcome }),
|
|
59
|
+
...(event.runId !== undefined && { provenanceRef: { runId: event.runId as RunId } }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractDoc(payload: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
64
|
+
const inner = payload.doc;
|
|
65
|
+
if (inner !== null && typeof inner === 'object' && !Array.isArray(inner)) {
|
|
66
|
+
return inner as Readonly<Record<string, unknown>>;
|
|
67
|
+
}
|
|
68
|
+
return payload;
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
// ============ Wire types ============
|
|
5
|
+
export { EVIDENCE_KINDS } from './types.js';
|
|
6
|
+
export type {
|
|
7
|
+
ActorKind,
|
|
8
|
+
ComplianceEvidence,
|
|
9
|
+
ComplianceProvider,
|
|
10
|
+
EmitEvidenceInput,
|
|
11
|
+
Evidence,
|
|
12
|
+
EvidenceActor,
|
|
13
|
+
EvidenceKind,
|
|
14
|
+
EvidenceOutcome,
|
|
15
|
+
EvidencePayload,
|
|
16
|
+
EvidenceSignature,
|
|
17
|
+
EvidenceSigner,
|
|
18
|
+
EvidenceSubject,
|
|
19
|
+
ListEvidenceFilter,
|
|
20
|
+
ProvenanceRef,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
|
|
23
|
+
// ============ Pipeline data types ============
|
|
24
|
+
export type {
|
|
25
|
+
EvidenceBundleBody,
|
|
26
|
+
EvidenceFilter,
|
|
27
|
+
EvidencePage,
|
|
28
|
+
GuardrailResultSummary,
|
|
29
|
+
ModelCallSummary,
|
|
30
|
+
RecordFromRunOptions,
|
|
31
|
+
RunEvidenceContext,
|
|
32
|
+
SignedEvidenceBundle,
|
|
33
|
+
ToolInvocationSummary,
|
|
34
|
+
} from './types.js';
|
|
35
|
+
|
|
36
|
+
// ============ Errors ============
|
|
37
|
+
export type {
|
|
38
|
+
ComplianceError,
|
|
39
|
+
EmitResult,
|
|
40
|
+
InvalidCursorError,
|
|
41
|
+
InvalidEvidenceError,
|
|
42
|
+
ListResult,
|
|
43
|
+
PersistenceError,
|
|
44
|
+
ProjectNotFoundError,
|
|
45
|
+
SignerFailureError,
|
|
46
|
+
SigningFailureError,
|
|
47
|
+
SigningKeyMissingError,
|
|
48
|
+
UnsupportedVersionError,
|
|
49
|
+
} from './errors.js';
|
|
50
|
+
|
|
51
|
+
// ============ Classifier ============
|
|
52
|
+
export type {
|
|
53
|
+
Classification,
|
|
54
|
+
ComplianceClassifierFile,
|
|
55
|
+
LoadedClassifier,
|
|
56
|
+
} from './classifier.js';
|
|
57
|
+
|
|
58
|
+
// ============ Generator interface + pure transform ============
|
|
59
|
+
export type { ComplianceEvidenceGenerator } from './generator.js';
|
|
60
|
+
export { auditEventToEvidence } from './generator.js';
|
|
61
|
+
|
|
62
|
+
// ============ Resolve + lifecycle event emitters ============
|
|
63
|
+
export { emitLifecycleEvent, emitResolveEvent } from './resolve-emit.js';
|
|
64
|
+
export type {
|
|
65
|
+
EmitLifecycleEventInput,
|
|
66
|
+
EmitResolveEventInput,
|
|
67
|
+
ResolveContext,
|
|
68
|
+
} from './resolve-emit.js';
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helpers for emitting `env-*` and `secret-*` audit events.
|
|
6
|
+
* `EnvBinding` and `SecretBinding` implementations call
|
|
7
|
+
* `emitResolveEvent` from their `resolve` paths and `emitLifecycleEvent`
|
|
8
|
+
* for `set` / `delete` / `rotate` / `revoke`.
|
|
9
|
+
*
|
|
10
|
+
* Writes land on the single `AuditEventBinding`. Classification
|
|
11
|
+
* (retention / signing / export) is a lens on top, not baked into the
|
|
12
|
+
* write path.
|
|
13
|
+
*
|
|
14
|
+
* Design guardrails:
|
|
15
|
+
* - Framework NEVER copies a secret plaintext `value` into a payload.
|
|
16
|
+
* Payloads carry only metadata (name, version, scope,
|
|
17
|
+
* caller, errorCode).
|
|
18
|
+
* - Emission is fire-and-forget: failures inside the binding are
|
|
19
|
+
* logged (via `console.warn`) but never poison the caller's op.
|
|
20
|
+
* - The `resolveContext.caller` discriminant maps to a distinct
|
|
21
|
+
* `actor` string.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
import type { AuditEvent, AuditEventBinding } from '@kindgi/audit-events';
|
|
27
|
+
import type { Scope } from '@kindgi/platform';
|
|
28
|
+
import type { EnvName, ProjectId, TenantId, Timestamp } from '@kindgi/types';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Uniform caller-of-resolution shape. Structurally identical to
|
|
32
|
+
* `@kindgi/api.ResolveContext` (kept as a duplicate declaration here
|
|
33
|
+
* to avoid a compliance → api dep cycle — TS structural typing
|
|
34
|
+
* makes them assignable across the boundary).
|
|
35
|
+
*/
|
|
36
|
+
export interface ResolveContext {
|
|
37
|
+
readonly runId?: string;
|
|
38
|
+
readonly deploymentId?: string;
|
|
39
|
+
readonly nodeId?: string;
|
|
40
|
+
readonly caller: 'dispatch' | 'deploy-sync' | 'admin-cli' | 'boot-bridge';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Input to `emitResolveEvent`. */
|
|
44
|
+
export interface EmitResolveEventInput {
|
|
45
|
+
readonly kind: 'env' | 'secret';
|
|
46
|
+
readonly scope: Scope;
|
|
47
|
+
readonly envName: EnvName;
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly outcome: 'succeeded' | 'failed';
|
|
50
|
+
readonly resolveContext: ResolveContext;
|
|
51
|
+
readonly version?: number;
|
|
52
|
+
readonly errorCode?: string;
|
|
53
|
+
readonly auditEvents: AuditEventBinding;
|
|
54
|
+
readonly tenantId: TenantId;
|
|
55
|
+
readonly projectId: ProjectId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Emit an `env-resolved` or `secret-resolved` audit event.
|
|
60
|
+
*
|
|
61
|
+
* The helper NEVER accepts nor emits the resolved value — the resolve
|
|
62
|
+
* outcome carries the plaintext to the caller; the audit event carries
|
|
63
|
+
* only the fact-of-resolution + version + scope + caller attribution.
|
|
64
|
+
*
|
|
65
|
+
* Fire-and-forget: any error is logged but swallowed. The resolve path
|
|
66
|
+
* must not fail because the audit sink is unreachable.
|
|
67
|
+
*/
|
|
68
|
+
export async function emitResolveEvent(input: EmitResolveEventInput): Promise<void> {
|
|
69
|
+
const kind = input.kind === 'env' ? 'env-resolved' : 'secret-resolved';
|
|
70
|
+
const payload = buildResolvePayload(input);
|
|
71
|
+
const actor = actorFromResolveContext(input.resolveContext);
|
|
72
|
+
const event = buildEvent({
|
|
73
|
+
tenantId: input.tenantId,
|
|
74
|
+
projectId: input.projectId,
|
|
75
|
+
kind,
|
|
76
|
+
outcome: input.outcome,
|
|
77
|
+
actor,
|
|
78
|
+
runId: input.resolveContext.runId,
|
|
79
|
+
payload,
|
|
80
|
+
});
|
|
81
|
+
await safeEmit(input.auditEvents, event);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Input to `emitLifecycleEvent`. Used for env `set` / `delete` and
|
|
86
|
+
* secret `set` / `rotate` / `revoke` — the non-resolve write ops that
|
|
87
|
+
* still need audit-trail attribution.
|
|
88
|
+
*/
|
|
89
|
+
export interface EmitLifecycleEventInput {
|
|
90
|
+
readonly evidenceKind: string;
|
|
91
|
+
readonly bindingKind: 'env' | 'secret';
|
|
92
|
+
readonly scope: Scope;
|
|
93
|
+
readonly envName: EnvName;
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly outcome: 'succeeded' | 'failed';
|
|
96
|
+
/**
|
|
97
|
+
* Caller-of-op attribution. Same shape as the resolve path — env
|
|
98
|
+
* `set` from an admin CLI produces `actor = 'admin'`; a
|
|
99
|
+
* deployment-sync `set` produces `actor = 'system:<deploymentId>'`.
|
|
100
|
+
*/
|
|
101
|
+
readonly resolveContext?: ResolveContext;
|
|
102
|
+
readonly version?: number;
|
|
103
|
+
readonly previousVersion?: number;
|
|
104
|
+
readonly errorCode?: string;
|
|
105
|
+
readonly hard?: boolean;
|
|
106
|
+
readonly reason?: string;
|
|
107
|
+
readonly auditEvents: AuditEventBinding;
|
|
108
|
+
readonly tenantId: TenantId;
|
|
109
|
+
readonly projectId: ProjectId;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Emit a non-resolve lifecycle event (`env-set` / `env-deleted` /
|
|
114
|
+
* `secret-set` / `secret-rotated` / `secret-rotation-started` /
|
|
115
|
+
* `secret-rotation-failed` / `secret-revoked` / `secret-hard-revoked`).
|
|
116
|
+
* Same fire-and-forget contract as `emitResolveEvent`.
|
|
117
|
+
*/
|
|
118
|
+
export async function emitLifecycleEvent(input: EmitLifecycleEventInput): Promise<void> {
|
|
119
|
+
const payload = buildLifecyclePayload(input);
|
|
120
|
+
const actor =
|
|
121
|
+
input.resolveContext !== undefined ? actorFromResolveContext(input.resolveContext) : undefined;
|
|
122
|
+
const event = buildEvent({
|
|
123
|
+
tenantId: input.tenantId,
|
|
124
|
+
projectId: input.projectId,
|
|
125
|
+
kind: input.evidenceKind,
|
|
126
|
+
outcome: input.outcome,
|
|
127
|
+
actor,
|
|
128
|
+
runId: input.resolveContext?.runId,
|
|
129
|
+
payload,
|
|
130
|
+
});
|
|
131
|
+
await safeEmit(input.auditEvents, event);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// -----------------------------------------------------------------------------
|
|
135
|
+
// Internals
|
|
136
|
+
// -----------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
interface BuildEventInput {
|
|
139
|
+
readonly tenantId: TenantId;
|
|
140
|
+
readonly projectId: ProjectId;
|
|
141
|
+
readonly kind: string;
|
|
142
|
+
readonly outcome: string;
|
|
143
|
+
readonly actor: string | undefined;
|
|
144
|
+
readonly runId: string | undefined;
|
|
145
|
+
readonly payload: Readonly<Record<string, unknown>>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildEvent(input: BuildEventInput): AuditEvent {
|
|
149
|
+
return {
|
|
150
|
+
id: randomUUID(),
|
|
151
|
+
tenantId: input.tenantId,
|
|
152
|
+
projectId: input.projectId,
|
|
153
|
+
kind: input.kind,
|
|
154
|
+
timestamp: new Date().toISOString() as unknown as Timestamp,
|
|
155
|
+
actor: input.actor ?? 'user:system',
|
|
156
|
+
outcome: input.outcome,
|
|
157
|
+
...(input.runId !== undefined && { runId: input.runId }),
|
|
158
|
+
payload: { v: 1, doc: input.payload },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function safeEmit(binding: AuditEventBinding, event: AuditEvent): Promise<void> {
|
|
163
|
+
try {
|
|
164
|
+
const result = await binding.append([event]);
|
|
165
|
+
if (result.kind === 'err') {
|
|
166
|
+
// eslint-disable-next-line no-console -- no injected logger here
|
|
167
|
+
console.warn(`[compliance] emit ${event.kind} audit event failed: ${result.error.message}`);
|
|
168
|
+
}
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
// eslint-disable-next-line no-console -- no injected logger here
|
|
171
|
+
console.warn(
|
|
172
|
+
`[compliance] emit ${event.kind} audit event threw: ${
|
|
173
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
174
|
+
}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Map the `resolveContext.caller` discriminant to a subject string.
|
|
181
|
+
* Format matches the audit-events `actor` convention
|
|
182
|
+
* (`<type>:<id>` — `agent:<runId>`, `system:<deploymentId>`, `user:admin`,
|
|
183
|
+
* `user:system`).
|
|
184
|
+
*/
|
|
185
|
+
function actorFromResolveContext(ctx: ResolveContext): string | undefined {
|
|
186
|
+
switch (ctx.caller) {
|
|
187
|
+
case 'dispatch':
|
|
188
|
+
return ctx.runId !== undefined ? `agent:${ctx.runId}` : 'agent:unknown';
|
|
189
|
+
case 'deploy-sync':
|
|
190
|
+
return ctx.deploymentId !== undefined ? `system:${ctx.deploymentId}` : 'system:deploy';
|
|
191
|
+
case 'admin-cli':
|
|
192
|
+
return 'user:admin';
|
|
193
|
+
case 'boot-bridge':
|
|
194
|
+
return 'user:system';
|
|
195
|
+
default:
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function scopeToPayloadShape(scope: Scope): Readonly<Record<string, unknown>> {
|
|
201
|
+
if (scope.kind === 'tenant') {
|
|
202
|
+
return { kind: 'tenant', tenantId: scope.tenantId as unknown as string };
|
|
203
|
+
}
|
|
204
|
+
if (scope.kind === 'org') {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'org',
|
|
207
|
+
tenantId: scope.tenantId as unknown as string,
|
|
208
|
+
orgId: scope.orgId as unknown as string,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
kind: 'project',
|
|
213
|
+
tenantId: scope.tenantId as unknown as string,
|
|
214
|
+
projectId: scope.projectId as unknown as string,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build the resolve-event payload. Structural guarantee: NO `value`
|
|
220
|
+
* key ever appears. Every field is either metadata (name,
|
|
221
|
+
* version, scope) or attribution (caller, errorCode).
|
|
222
|
+
*/
|
|
223
|
+
function buildResolvePayload(input: EmitResolveEventInput): Readonly<Record<string, unknown>> {
|
|
224
|
+
const payload: Record<string, unknown> = {
|
|
225
|
+
scope: scopeToPayloadShape(input.scope),
|
|
226
|
+
envName: input.envName as unknown as string,
|
|
227
|
+
name: input.name,
|
|
228
|
+
caller: input.resolveContext.caller,
|
|
229
|
+
};
|
|
230
|
+
if (input.version !== undefined) payload.recordVersion = input.version;
|
|
231
|
+
if (input.errorCode !== undefined) payload.errorCode = input.errorCode;
|
|
232
|
+
if (input.resolveContext.nodeId !== undefined) payload.nodeId = input.resolveContext.nodeId;
|
|
233
|
+
if (input.resolveContext.runId !== undefined) payload.runId = input.resolveContext.runId;
|
|
234
|
+
return payload;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build the lifecycle-event payload. Same structural rule as
|
|
239
|
+
* `buildResolvePayload` — never carries the value.
|
|
240
|
+
*/
|
|
241
|
+
function buildLifecyclePayload(input: EmitLifecycleEventInput): Readonly<Record<string, unknown>> {
|
|
242
|
+
const payload: Record<string, unknown> = {
|
|
243
|
+
scope: scopeToPayloadShape(input.scope),
|
|
244
|
+
envName: input.envName as unknown as string,
|
|
245
|
+
name: input.name,
|
|
246
|
+
};
|
|
247
|
+
if (input.resolveContext !== undefined) payload.caller = input.resolveContext.caller;
|
|
248
|
+
if (input.version !== undefined) payload.recordVersion = input.version;
|
|
249
|
+
if (input.previousVersion !== undefined) payload.previousVersion = input.previousVersion;
|
|
250
|
+
if (input.errorCode !== undefined) payload.errorCode = input.errorCode;
|
|
251
|
+
if (input.hard !== undefined) payload.hard = input.hard;
|
|
252
|
+
if (input.reason !== undefined) payload.reason = input.reason;
|
|
253
|
+
if (input.resolveContext?.nodeId !== undefined) payload.nodeId = input.resolveContext.nodeId;
|
|
254
|
+
if (input.resolveContext?.runId !== undefined) payload.runId = input.resolveContext.runId;
|
|
255
|
+
return payload;
|
|
256
|
+
}
|