diff --git a/README.md b/README.md index a094ab9..ea8e635 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ func main() { data := events.EvidenceIngestedData{ ContentDigest: "sha256:abc123...", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "s3://evidence-bucket/my-app-v1/evaluation-log.json", SubjectID: "my-app-v1", } @@ -46,7 +47,23 @@ func main() { | Type | Constant | Description | |------|----------|-------------| -| `dev.complytime.evidence.ingested` | `events.TypeEvidenceIngested` | Evidence accepted for processing | +| `dev.complytime.evidence.ingested` | `events.TypeEvidenceIngested` | Evidence accepted for processing, before validation | +| `dev.complytime.evidence.sealed` | `events.TypeEvidenceSealed` | Evidence validated and sealed into a unit of work | +| `dev.complytime.evidence.quarantined` | `events.TypeEvidenceQuarantined` | Evidence failed validation and was quarantined | + +### Correlation + +Events do not carry a dedicated correlation attribute. + +- **Join an artifact's lifecycle events** (`ingested` → + `sealed`|`quarantined`) on the shared `contentDigest`. Caveat: a + `quarantined` event whose `reason` is a content-digest mismatch is the one case + where the digest is itself in doubt. + +- **Trace across services** using the + [CloudEvents Distributed Tracing extension](https://github.com/cloudevents/spec/blob/main/cloudevents/extensions/distributed-tracing.md) + (`traceparent`/`tracestate`, W3C Trace Context). This is the observability + plane, set and propagated by the producer's tracing SDK, not by this library. ### Payload Examples @@ -55,9 +72,9 @@ Example CloudEvents JSON payloads are in | File | Description | |------|-------------| -| [`evidence-ingested.json`](api/events/examples/evidence-ingested.json) | Common payload with required fields and `storageRef` | -| [`evidence-ingested-minimal.json`](api/events/examples/evidence-ingested-minimal.json) | Required data fields only (no optional fields) | -| [`evidence-ingested-with-shard.json`](api/events/examples/evidence-ingested-with-shard.json) | All fields including optional `shardId` | +| [`evidence-ingested.json`](api/events/examples/evidence-ingested.json) | Ingested outcome; all data fields including `storageRef` | +| [`evidence-sealed.json`](api/events/examples/evidence-sealed.json) | Sealed outcome after successful validation | +| [`evidence-quarantined.json`](api/events/examples/evidence-quarantined.json) | Quarantined outcome after failed validation, with `reason` | These examples conform to the [JSON Schema](api/events/schemas/) and [AsyncAPI spec](api/events/asyncapi.yaml). They are hand-maintained @@ -114,7 +131,7 @@ task check Example sentinel field: ```go - _ struct{} `asyncapi:"channel:core.widget.created.{ownerId},param:ownerId=The widget owner,stream:WIDGETS,type:dev.complytime.widget.created,send:Published when a widget is created,receive:Consume widget-created events,description:Widget creation pipeline"` + _ struct{} `asyncapi:"channel:complyapi.widget.created.{ownerId},param:ownerId=The widget owner,stream:WIDGETS,type:dev.complytime.widget.created,send:Published when a widget is created,receive:Consume widget-created events,description:Widget creation pipeline"` ``` 2. Add `asyncapi-field:"description:..."` tags on each struct field for diff --git a/api/events/asyncapi.yaml b/api/events/asyncapi.yaml index fd5a5ac..609a0a6 100644 --- a/api/events/asyncapi.yaml +++ b/api/events/asyncapi.yaml @@ -2,7 +2,7 @@ asyncapi: 3.0.0 info: title: ComplyTime API Events - version: 0.1.0 + version: 0.2.0 description: |- Event contract for the ComplyTime evidence lifecycle. @@ -21,7 +21,7 @@ servers: protocol: nats channels: evidenceIngested: - address: core.evidence.ingested.{subjectId} + address: complyapi.evidence.ingested.{subjectId} description: Evidence ingestion pipeline for compliance artifacts parameters: subjectId: @@ -29,12 +29,40 @@ channels: messages: EvidenceIngested: $ref: '#/components/messages/EvidenceIngested' + evidenceQuarantined: + address: complyapi.evidence.quarantined.{subjectId} + description: Evidence quarantine pipeline for compliance artifacts + parameters: + subjectId: + description: The compliance subject identifier + messages: + EvidenceQuarantined: + $ref: '#/components/messages/EvidenceQuarantined' + evidenceSealed: + address: complyapi.evidence.sealed.{subjectId} + description: Evidence sealing pipeline for compliance artifacts + parameters: + subjectId: + description: The compliance subject identifier + messages: + EvidenceSealed: + $ref: '#/components/messages/EvidenceSealed' operations: consumeEvidenceIngested: action: receive summary: Consume evidence-ingested events channel: $ref: '#/channels/evidenceIngested' + consumeEvidenceQuarantined: + action: receive + summary: Consume evidence-quarantined events + channel: + $ref: '#/channels/evidenceQuarantined' + consumeEvidenceSealed: + action: receive + summary: Consume evidence-sealed events + channel: + $ref: '#/channels/evidenceSealed' publishEvidenceIngested: action: send summary: Published when evidence is accepted for processing; before sealing @@ -44,6 +72,24 @@ operations: nats: x-stream: EVIDENCE bindingVersion: 0.1.0 + publishEvidenceQuarantined: + action: send + summary: Published when a worker fails to validate evidence and quarantines it + channel: + $ref: '#/channels/evidenceQuarantined' + bindings: + nats: + x-stream: EVIDENCE + bindingVersion: 0.1.0 + publishEvidenceSealed: + action: send + summary: Published when a worker validates and seals evidence into a unit of work + channel: + $ref: '#/channels/evidenceSealed' + bindings: + nats: + x-stream: EVIDENCE + bindingVersion: 0.1.0 components: messages: EvidenceIngested: @@ -52,6 +98,18 @@ components: contentType: application/cloudevents+json payload: $ref: '#/components/schemas/EvidenceIngestedCloudEvent' + EvidenceQuarantined: + name: EvidenceQuarantined + title: Evidence Quarantined + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/EvidenceQuarantinedCloudEvent' + EvidenceSealed: + name: EvidenceSealed + title: Evidence Sealed + contentType: application/cloudevents+json + payload: + $ref: '#/components/schemas/EvidenceSealedCloudEvent' schemas: EvidenceIngestedCloudEvent: type: object @@ -94,10 +152,71 @@ components: description: |- EvidenceIngestedData is the CloudEvents data payload for evidence.ingested events. + required: + - contentDigest + - artifactType + - storageRef + - subjectId + properties: + artifactType: + type: string + description: Gemara artifact type + contentDigest: + type: string + description: SHA-256 digest of the evidence artifact + storageRef: + type: string + description: URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://) + subjectId: + type: string + description: Compliance subject identifier + EvidenceQuarantinedCloudEvent: + type: object + description: CloudEvents v1.0 envelope for dev.complytime.evidence.quarantined + required: + - specversion + - id + - type + - source + - subject + - time + - datacontenttype + - data + properties: + data: + $ref: '#/components/schemas/EvidenceQuarantinedData' + datacontenttype: + type: string + const: application/json + id: + type: string + format: uuid + source: + type: string + description: URI identifying the producing service + specversion: + type: string + const: "1.0" + subject: + type: string + description: The compliance subject identifier + time: + type: string + format: date-time + type: + type: string + const: dev.complytime.evidence.quarantined + EvidenceQuarantinedData: + type: object + description: |- + EvidenceQuarantinedData is the CloudEvents data payload for + evidence.quarantined events, published when a worker fails to validate + an ingested artifact. required: - contentDigest - artifactType - subjectId + - reason properties: artifactType: type: string @@ -105,12 +224,70 @@ components: contentDigest: type: string description: SHA-256 digest of the evidence artifact - shardId: + reason: + type: string + description: Why validation failed + subjectId: + type: string + description: Compliance subject identifier + EvidenceSealedCloudEvent: + type: object + description: CloudEvents v1.0 envelope for dev.complytime.evidence.sealed + required: + - specversion + - id + - type + - source + - subject + - time + - datacontenttype + - data + properties: + data: + $ref: '#/components/schemas/EvidenceSealedData' + datacontenttype: type: string - description: Subject shard identifier (null when sharding is not configured) + const: application/json + id: + type: string + format: uuid + source: + type: string + description: URI identifying the producing service + specversion: + type: string + const: "1.0" + subject: + type: string + description: The compliance subject identifier + time: + type: string + format: date-time + type: + type: string + const: dev.complytime.evidence.sealed + EvidenceSealedData: + type: object + description: |- + EvidenceSealedData is the CloudEvents data payload for evidence.sealed + events. It carries the same evidence identity as EvidenceIngestedData + but signals that a worker has validated the artifact and sealed it into + a unit of work. + required: + - contentDigest + - artifactType + - storageRef + - subjectId + properties: + artifactType: + type: string + description: Gemara artifact type + contentDigest: + type: string + description: SHA-256 digest of the evidence artifact storageRef: type: string - description: Internal storage reference + description: URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://) subjectId: type: string description: Compliance subject identifier diff --git a/api/events/examples/evidence-ingested-minimal.json b/api/events/examples/evidence-ingested-minimal.json deleted file mode 100644 index 32c2eee..0000000 --- a/api/events/examples/evidence-ingested-minimal.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "specversion": "1.0", - "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "type": "dev.complytime.evidence.ingested", - "source": "complytime-gateway", - "subject": "my-app-v1", - "time": "2026-08-20T14:30:00Z", - "datacontenttype": "application/json", - "data": { - "contentDigest": "sha256:a3f2b8c1d4e5f67890abcdef1234567890abcdef1234567890abcdef12345678", - "artifactType": "application/vnd.gemara.evaluation-log+json", - "subjectId": "my-app-v1" - } -} diff --git a/api/events/examples/evidence-ingested-with-shard.json b/api/events/examples/evidence-ingested-with-shard.json deleted file mode 100644 index d1a5fe6..0000000 --- a/api/events/examples/evidence-ingested-with-shard.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "specversion": "1.0", - "id": "c9a1e2b3-d4f5-6789-0abc-def123456789", - "type": "dev.complytime.evidence.ingested", - "source": "complytime-gateway", - "subject": "my-app-v1", - "time": "2026-08-20T14:30:00Z", - "datacontenttype": "application/json", - "data": { - "contentDigest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "store/evidence/2026/08/20/c9a1e2b3", - "subjectId": "my-app-v1", - "shardId": "shard-west-1" - } -} diff --git a/api/events/examples/evidence-ingested.json b/api/events/examples/evidence-ingested.json index 1fb8862..5531df1 100644 --- a/api/events/examples/evidence-ingested.json +++ b/api/events/examples/evidence-ingested.json @@ -9,7 +9,7 @@ "data": { "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "artifactType": "application/vnd.gemara.evaluation-log+json", - "storageRef": "store/evidence/2026/08/20/a1b2c3d4", + "storageRef": "locker://store/evidence/2026/08/20/a1b2c3d4", "subjectId": "my-app-v1" } } diff --git a/api/events/examples/evidence-quarantined.json b/api/events/examples/evidence-quarantined.json new file mode 100644 index 0000000..869f2db --- /dev/null +++ b/api/events/examples/evidence-quarantined.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "type": "dev.complytime.evidence.quarantined", + "source": "complytime-worker", + "subject": "my-app-v1", + "time": "2026-08-20T14:32:00Z", + "datacontenttype": "application/json", + "data": { + "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "artifactType": "application/vnd.gemara.evaluation-log+json", + "subjectId": "my-app-v1", + "reason": "content digest mismatch" + } +} diff --git a/api/events/examples/evidence-sealed.json b/api/events/examples/evidence-sealed.json new file mode 100644 index 0000000..0d8e10f --- /dev/null +++ b/api/events/examples/evidence-sealed.json @@ -0,0 +1,15 @@ +{ + "specversion": "1.0", + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "type": "dev.complytime.evidence.sealed", + "source": "complytime-worker", + "subject": "my-app-v1", + "time": "2026-08-20T14:31:00Z", + "datacontenttype": "application/json", + "data": { + "contentDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "artifactType": "application/vnd.gemara.evaluation-log+json", + "storageRef": "s3://evidence/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "subjectId": "my-app-v1" + } +} diff --git a/api/events/schemas/EvidenceIngestedData.schema.json b/api/events/schemas/EvidenceIngestedData.schema.json index 309b1bc..c47e7a4 100644 --- a/api/events/schemas/EvidenceIngestedData.schema.json +++ b/api/events/schemas/EvidenceIngestedData.schema.json @@ -12,12 +12,8 @@ "description": "SHA-256 digest of the evidence artifact", "type": "string" }, - "shardId": { - "description": "Subject shard identifier (null when sharding is not configured)", - "type": "string" - }, "storageRef": { - "description": "Internal storage reference", + "description": "URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://)", "type": "string" }, "subjectId": { @@ -28,6 +24,7 @@ "required": [ "contentDigest", "artifactType", + "storageRef", "subjectId" ], "type": "object" diff --git a/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json b/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json new file mode 100644 index 0000000..d798ea7 --- /dev/null +++ b/api/events/schemas/EvidenceQuarantinedCloudEvent.schema.json @@ -0,0 +1,50 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceQuarantinedCloudEvent.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "CloudEvents v1.0 envelope for dev.complytime.evidence.quarantined", + "properties": { + "data": { + "$ref": "EvidenceQuarantinedData.schema.json" + }, + "datacontenttype": { + "const": "application/json", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "source": { + "description": "URI identifying the producing service", + "type": "string" + }, + "specversion": { + "const": "1.0", + "type": "string" + }, + "subject": { + "description": "The compliance subject identifier", + "type": "string" + }, + "time": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "dev.complytime.evidence.quarantined", + "type": "string" + } + }, + "required": [ + "specversion", + "id", + "type", + "source", + "subject", + "time", + "datacontenttype", + "data" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceQuarantinedData.schema.json b/api/events/schemas/EvidenceQuarantinedData.schema.json new file mode 100644 index 0000000..d208990 --- /dev/null +++ b/api/events/schemas/EvidenceQuarantinedData.schema.json @@ -0,0 +1,31 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceQuarantinedData.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "EvidenceQuarantinedData is the CloudEvents data payload for\nevidence.quarantined events, published when a worker fails to validate\nan ingested artifact.", + "properties": { + "artifactType": { + "description": "Gemara artifact type", + "type": "string" + }, + "contentDigest": { + "description": "SHA-256 digest of the evidence artifact", + "type": "string" + }, + "reason": { + "description": "Why validation failed", + "type": "string" + }, + "subjectId": { + "description": "Compliance subject identifier", + "type": "string" + } + }, + "required": [ + "contentDigest", + "artifactType", + "subjectId", + "reason" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceSealedCloudEvent.schema.json b/api/events/schemas/EvidenceSealedCloudEvent.schema.json new file mode 100644 index 0000000..fc4914b --- /dev/null +++ b/api/events/schemas/EvidenceSealedCloudEvent.schema.json @@ -0,0 +1,50 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceSealedCloudEvent.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "CloudEvents v1.0 envelope for dev.complytime.evidence.sealed", + "properties": { + "data": { + "$ref": "EvidenceSealedData.schema.json" + }, + "datacontenttype": { + "const": "application/json", + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "source": { + "description": "URI identifying the producing service", + "type": "string" + }, + "specversion": { + "const": "1.0", + "type": "string" + }, + "subject": { + "description": "The compliance subject identifier", + "type": "string" + }, + "time": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "dev.complytime.evidence.sealed", + "type": "string" + } + }, + "required": [ + "specversion", + "id", + "type", + "source", + "subject", + "time", + "datacontenttype", + "data" + ], + "type": "object" +} diff --git a/api/events/schemas/EvidenceSealedData.schema.json b/api/events/schemas/EvidenceSealedData.schema.json new file mode 100644 index 0000000..b5cb582 --- /dev/null +++ b/api/events/schemas/EvidenceSealedData.schema.json @@ -0,0 +1,31 @@ +{ + "$comment": "Generated by cmd/asyncapi-gen from events/events.go — do not edit manually; run 'go generate ./events/...' to regenerate.", + "$id": "EvidenceSealedData.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "EvidenceSealedData is the CloudEvents data payload for evidence.sealed\nevents. It carries the same evidence identity as EvidenceIngestedData\nbut signals that a worker has validated the artifact and sealed it into\na unit of work.", + "properties": { + "artifactType": { + "description": "Gemara artifact type", + "type": "string" + }, + "contentDigest": { + "description": "SHA-256 digest of the evidence artifact", + "type": "string" + }, + "storageRef": { + "description": "URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://)", + "type": "string" + }, + "subjectId": { + "description": "Compliance subject identifier", + "type": "string" + } + }, + "required": [ + "contentDigest", + "artifactType", + "storageRef", + "subjectId" + ], + "type": "object" +} diff --git a/cmd/asyncapi-gen/jsonschema.go b/cmd/asyncapi-gen/jsonschema.go index 8ac3712..35e5eba 100644 --- a/cmd/asyncapi-gen/jsonschema.go +++ b/cmd/asyncapi-gen/jsonschema.go @@ -80,7 +80,7 @@ func BuildEnvelopeJSONSchema(spec EventSpec) JSONSchema { // each event spec to the given directory. Creates the directory if it // does not exist. func WriteJSONSchemas(specs []EventSpec, dir string) error { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // G703: dir is the developer-supplied -schemas-dir flag from the go:generate directive, not untrusted input; 0o755 is correct for output directories (SC-005) return fmt.Errorf("creating schemas directory: %w", err) } diff --git a/cmd/asyncapi-gen/main.go b/cmd/asyncapi-gen/main.go index 418eea9..13b2c66 100644 --- a/cmd/asyncapi-gen/main.go +++ b/cmd/asyncapi-gen/main.go @@ -83,9 +83,9 @@ func run(opts Options, stdout, stderr io.Writer) error { if err := WriteJSONSchemas(specs, opts.SchemasDir); err != nil { return fmt.Errorf("schema write error: %w", err) } - fmt.Fprintf(stdout, "asyncapi-gen: wrote JSON schemas to %s\n", opts.SchemasDir) + fmt.Fprintf(stdout, "asyncapi-gen: wrote JSON schemas to %s\n", opts.SchemasDir) //nolint:gosec // G705: stdout is a CLI status stream, not an HTML/browser sink; XSS does not apply } - fmt.Fprintf(stdout, "asyncapi-gen: wrote %s (%d event(s))\n", opts.Output, len(specs)) + fmt.Fprintf(stdout, "asyncapi-gen: wrote %s (%d event(s))\n", opts.Output, len(specs)) //nolint:gosec // G705: stdout is a CLI status stream, not an HTML/browser sink; XSS does not apply return nil } diff --git a/cmd/asyncapi-gen/parser.go b/cmd/asyncapi-gen/parser.go index 7053663..fa9117d 100644 --- a/cmd/asyncapi-gen/parser.go +++ b/cmd/asyncapi-gen/parser.go @@ -38,7 +38,7 @@ type FieldSpec struct { // per annotated struct. Returns an error if the file cannot be parsed or // a required tag key is missing. func ParseFile(path string) ([]EventSpec, error) { - src, err := os.ReadFile(path) + src, err := os.ReadFile(path) //nolint:gosec // G703: path is the developer-supplied -input flag from the go:generate directive, not untrusted input if err != nil { return nil, fmt.Errorf("reading file: %w", err) } diff --git a/docs/versioning.md b/docs/versioning.md index ba9543e..e182330 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -23,15 +23,25 @@ subscribers or signals nothing. | Layer | Field | Example | Versions | Reacts | |-------|-------|---------|----------|--------| -| NATS subject | channel address | `core.evidence.ingested.{subjectId}` | Nothing — routing address only | Broker (routing) | +| NATS subject | channel address | `complyapi.evidence.ingested.{subjectId}` | Nothing — routing address only | Broker (routing) | | CloudEvents type | `type` | `dev.complytime.evidence.ingested` | The event's payload contract (breaking changes) | Consumer per-message dispatch | -| AsyncAPI contract | `info.version` | `0.1.0` | The whole published contract | Humans, codegen tooling | +| AsyncAPI contract | `info.version` | `0.2.0` | The whole published contract | Humans, codegen tooling | | CloudEvents envelope | `specversion` | `1.0` | The CNCF envelope format — not ours | Nobody (hands off) | ### NATS subject — frozen routing address -The subject is where a subscriber listens (ADR-0020, `domain.action.entity` -hierarchy). It MUST NOT carry a version segment. Version information belongs in +The subject is where a subscriber listens (ADR-0020). In this codebase that +hierarchy is `domain.entity.action.{param}` — e.g. +`complyapi.evidence.ingested.{subjectId}`, `complyapi.evidence.sealed.{subjectId}`, +`complyapi.evidence.quarantined.{subjectId}`. The subject's `domain` segment +(`complyapi`) names the library/service that owns this event contract — it +is a routing namespace only, and it does not need to match the CloudEvents +`type`'s reverse-DNS namespace (`dev.complytime`, the org's domain). The two +namespaces serve different audiences: the subject namespace scopes NATS +routing/permissions, the `type` namespace scopes payload-contract identity. +Do not infer one from the other; dispatch on `type`, subscribe on subject. + +The subject MUST NOT carry a version segment. Version information belongs in the CloudEvents `type` (see below), per ADR-0021, which routes and filters on `type` and `source`. @@ -39,8 +49,10 @@ Keeping version out of the subject means subscribers bind once, with a wildcard, and never re-subscribe: ``` -core.evidence.ingested.* # every subjectId -core.evidence.ingested.> # every subjectId and any deeper segments +complyapi.evidence.ingested.* # every subjectId, ingested outcome only +complyapi.evidence.sealed.* # every subjectId, sealed outcome only +complyapi.evidence.quarantined.* # every subjectId, quarantined outcome only +complyapi.evidence.> # every outcome, every subjectId, any deeper segments ``` That single binding survives both a new `subjectId` and a new payload version, diff --git a/events/events.go b/events/events.go index 41d7454..17ee886 100644 --- a/events/events.go +++ b/events/events.go @@ -4,10 +4,12 @@ // evidence lifecycle. package events -//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.1.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 +//go:generate go run ../cmd/asyncapi-gen -input ./events.go -output ../api/events/asyncapi.yaml -schemas-dir ../api/events/schemas -title "ComplyTime API Events" -version 0.2.0 -description "Event contract for the ComplyTime evidence lifecycle.\n\nAll public events use CloudEvents v1.0 envelope (JSON format).\nThis spec is generated from Go types in the events package via cmd/asyncapi-gen.\nDo not edit manually — run 'go generate ./events/...' to regenerate." -license Apache-2.0 -contact-name ComplyTime -contact-url https://github.com/complytime/complyapi -server nats://localhost:4222 import ( "errors" + "fmt" + "regexp" "time" cloudevents "github.com/cloudevents/sdk-go/v2" @@ -15,19 +17,57 @@ import ( "github.com/google/uuid" ) +// subjectIDPattern restricts subjectId to characters that are safe as a +// literal NATS subject token: alphanumerics, dash, and underscore. It +// excludes ".", "*", and ">" — the NATS token separator and wildcards — so +// a subjectId can never reshape or collide with a subscriber's wildcard +// binding (e.g. "complyapi.evidence.ingested.*"). +var subjectIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// storageRefPattern requires storageRef to carry a URI-style scheme prefix +// (e.g. "s3://", "gcs://", "locker://") per RFC 3986 scheme syntax. The set +// of valid backends is not fixed, so this validates general shape rather +// than an enumerated allowlist. +var storageRefPattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*://`) + +// validateSubjectID returns an error if subjectID is empty or contains any +// character unsafe for use as a literal NATS subject token. +func validateSubjectID(subjectID string) error { + if subjectID == "" { + return errors.New("subjectId must not be empty") + } + if !subjectIDPattern.MatchString(subjectID) { + return fmt.Errorf("subjectId %q must match %s", subjectID, subjectIDPattern.String()) + } + return nil +} + +// validateStorageRef returns an error if storageRef is empty or lacks a +// URI-style scheme prefix (e.g. "s3://", "gcs://", "locker://"). +func validateStorageRef(storageRef string) error { + if storageRef == "" { + return errors.New("storageRef must not be empty") + } + if !storageRefPattern.MatchString(storageRef) { + return fmt.Errorf("storageRef %q must have a URI scheme prefix (e.g. s3://, gcs://, locker://)", storageRef) + } + return nil +} + +// TypeEvidenceIngested is the CloudEvents type for evidence accepted for +// processing, before validation. const TypeEvidenceIngested = "dev.complytime.evidence.ingested" // EvidenceIngestedData is the CloudEvents data payload for // evidence.ingested events. type EvidenceIngestedData struct { //nolint:unused - _ struct{} `asyncapi:"channel:core.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing; before sealing,receive:Consume evidence-ingested events,description:Evidence ingestion pipeline for compliance artifacts"` + _ struct{} `asyncapi:"channel:complyapi.evidence.ingested.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.ingested,send:Published when evidence is accepted for processing; before sealing,receive:Consume evidence-ingested events,description:Evidence ingestion pipeline for compliance artifacts"` - ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` - ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` - StorageRef string `json:"storageRef,omitempty" asyncapi-field:"description:Internal storage reference"` - SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` - ShardID *string `json:"shardId,omitempty" asyncapi-field:"description:Subject shard identifier (null when sharding is not configured)"` + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference consumers use to fetch the evidence artifact (must include a scheme prefix, e.g. s3://, gcs://, locker://)"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` } // NewEvidenceIngestedEvent constructs a CloudEvents v1.0 event with @@ -39,6 +79,18 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) if subject == "" { return cloudevents.Event{}, errors.New("subject must not be empty") } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } + if err := validateStorageRef(data.StorageRef); err != nil { + return cloudevents.Event{}, err + } e := event.New(cloudevents.VersionV1) e.SetID(uuid.New().String()) @@ -52,3 +104,109 @@ func NewEvidenceIngestedEvent(source, subject string, data EvidenceIngestedData) } return e, nil } + +// TypeEvidenceSealed is the CloudEvents type for evidence a worker has +// validated and sealed into a unit of work. +const TypeEvidenceSealed = "dev.complytime.evidence.sealed" + +// EvidenceSealedData is the CloudEvents data payload for evidence.sealed +// events. It carries the same evidence identity as EvidenceIngestedData +// but signals that a worker has validated the artifact and sealed it into +// a unit of work. +type EvidenceSealedData struct { + //nolint:unused + _ struct{} `asyncapi:"channel:complyapi.evidence.sealed.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.sealed,send:Published when a worker validates and seals evidence into a unit of work,receive:Consume evidence-sealed events,description:Evidence sealing pipeline for compliance artifacts"` + + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + StorageRef string `json:"storageRef" asyncapi-field:"description:URI-style storage reference to the sealed WORM evidence object consumers fetch (must include a scheme prefix, e.g. s3://, gcs://, locker://)"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` +} + +// NewEvidenceSealedEvent constructs a CloudEvents v1.0 event with the +// given source, subject, and data payload for a sealed evidence outcome. +func NewEvidenceSealedEvent(source, subject string, data EvidenceSealedData) (cloudevents.Event, error) { + if source == "" { + return cloudevents.Event{}, errors.New("source must not be empty") + } + if subject == "" { + return cloudevents.Event{}, errors.New("subject must not be empty") + } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } + if err := validateStorageRef(data.StorageRef); err != nil { + return cloudevents.Event{}, err + } + + e := event.New(cloudevents.VersionV1) + e.SetID(uuid.New().String()) + e.SetType(TypeEvidenceSealed) + e.SetSource(source) + e.SetSubject(subject) + e.SetTime(time.Now()) + e.SetDataContentType("application/json") + if err := e.SetData(cloudevents.ApplicationJSON, data); err != nil { + return cloudevents.Event{}, err + } + return e, nil +} + +// TypeEvidenceQuarantined is the CloudEvents type for evidence a worker +// failed to validate and quarantined. +const TypeEvidenceQuarantined = "dev.complytime.evidence.quarantined" + +// EvidenceQuarantinedData is the CloudEvents data payload for +// evidence.quarantined events, published when a worker fails to validate +// an ingested artifact. +type EvidenceQuarantinedData struct { + //nolint:unused + _ struct{} `asyncapi:"channel:complyapi.evidence.quarantined.{subjectId},param:subjectId=The compliance subject identifier,stream:EVIDENCE,type:dev.complytime.evidence.quarantined,send:Published when a worker fails to validate evidence and quarantines it,receive:Consume evidence-quarantined events,description:Evidence quarantine pipeline for compliance artifacts"` + + ContentDigest string `json:"contentDigest" asyncapi-field:"description:SHA-256 digest of the evidence artifact"` + ArtifactType string `json:"artifactType" asyncapi-field:"description:Gemara artifact type"` + SubjectID string `json:"subjectId" asyncapi-field:"description:Compliance subject identifier"` + Reason string `json:"reason" asyncapi-field:"description:Why validation failed"` +} + +// NewEvidenceQuarantinedEvent constructs a CloudEvents v1.0 event with the +// given source, subject, and data payload for a quarantined evidence +// outcome. data.Reason must not be empty. +func NewEvidenceQuarantinedEvent(source, subject string, data EvidenceQuarantinedData) (cloudevents.Event, error) { + if source == "" { + return cloudevents.Event{}, errors.New("source must not be empty") + } + if subject == "" { + return cloudevents.Event{}, errors.New("subject must not be empty") + } + if err := validateSubjectID(data.SubjectID); err != nil { + return cloudevents.Event{}, err + } + if data.ContentDigest == "" { + return cloudevents.Event{}, errors.New("contentDigest must not be empty") + } + if data.ArtifactType == "" { + return cloudevents.Event{}, errors.New("artifactType must not be empty") + } + if data.Reason == "" { + return cloudevents.Event{}, errors.New("reason must not be empty") + } + + e := event.New(cloudevents.VersionV1) + e.SetID(uuid.New().String()) + e.SetType(TypeEvidenceQuarantined) + e.SetSource(source) + e.SetSubject(subject) + e.SetTime(time.Now()) + e.SetDataContentType("application/json") + if err := e.SetData(cloudevents.ApplicationJSON, data); err != nil { + return cloudevents.Event{}, err + } + return e, nil +} diff --git a/events/events_test.go b/events/events_test.go index 197e8c9..781327a 100644 --- a/events/events_test.go +++ b/events/events_test.go @@ -3,12 +3,14 @@ package events import ( + "bytes" "encoding/json" "os" "path/filepath" "testing" cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/santhosh-tekuri/jsonschema/v6" ) func TestTypeEvidenceIngestedConstant(t *testing.T) { @@ -49,59 +51,11 @@ func TestEvidenceIngestedDataJSON(t *testing.T) { } } -func TestEvidenceIngestedDataJSONOmitsOptionalFields(t *testing.T) { - data := EvidenceIngestedData{ - ContentDigest: "sha256:abc123", - ArtifactType: "application/vnd.gemara.evaluation-log+json", - SubjectID: "my-app-v1", - } - - b, err := json.Marshal(data) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - raw := make(map[string]interface{}) - if err := json.Unmarshal(b, &raw); err != nil { - t.Fatalf("Unmarshal to map: %v", err) - } - - if _, ok := raw["storageRef"]; ok { - t.Error("storageRef should be omitted when empty") - } - if _, ok := raw["shardId"]; ok { - t.Error("shardId should be omitted when nil") - } -} - -func TestEvidenceIngestedDataJSONIncludesShardID(t *testing.T) { - shard := "shard-1" - data := EvidenceIngestedData{ - ContentDigest: "sha256:abc123", - ArtifactType: "application/vnd.gemara.evaluation-log+json", - SubjectID: "my-app-v1", - ShardID: &shard, - } - - b, err := json.Marshal(data) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - raw := make(map[string]interface{}) - if err := json.Unmarshal(b, &raw); err != nil { - t.Fatalf("Unmarshal to map: %v", err) - } - - if raw["shardId"] != "shard-1" { - t.Errorf("shardId = %v, want %q", raw["shardId"], "shard-1") - } -} - func TestNewEvidenceIngestedEvent(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", SubjectID: "my-app-v1", } @@ -160,6 +114,29 @@ func TestNewEvidenceIngestedEventEmptySource(t *testing.T) { } } +func TestNewEvidenceIngestedEventEmptyStorageRef(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty storageRef") + } +} + +func TestNewEvidenceIngestedEventStorageRefMissingScheme(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "store/evidence/2026/08/20/a1b2c3d4", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for storageRef missing URI scheme prefix") + } +} + func TestNewEvidenceIngestedEventEmptySubject(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", @@ -173,11 +150,33 @@ func TestNewEvidenceIngestedEventEmptySubject(t *testing.T) { } } +func TestNewEvidenceIngestedEventEmptyContentDigest(t *testing.T) { + data := EvidenceIngestedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceIngestedEventEmptyArtifactType(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + StorageRef: "locker://ref/123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + func TestNewEvidenceIngestedEventWireFormatRoundTrip(t *testing.T) { data := EvidenceIngestedData{ ContentDigest: "sha256:abc123", ArtifactType: "application/vnd.gemara.evaluation-log+json", - StorageRef: "ref/456", + StorageRef: "locker://ref/456", SubjectID: "my-app-v1", } @@ -227,8 +226,229 @@ func TestNewEvidenceIngestedEventWireFormatRoundTrip(t *testing.T) { } } +func TestNewEvidenceIngestedEventInvalidSubjectID(t *testing.T) { + cases := []struct { + name string + subjectID string + }{ + {"contains dot", "my-app.v1"}, + {"contains star wildcard", "my-app-*"}, + {"contains gt wildcard", "my-app->"}, + {"contains space", "my app"}, + {"empty", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: tc.subjectID, + } + _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data) + if err == nil { + t.Errorf("expected error for subjectId %q", tc.subjectID) + } + }) + } +} + +func TestNewEvidenceIngestedEventValidSubjectIDCharset(t *testing.T) { + data := EvidenceIngestedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "locker://ref/123", + SubjectID: "my-app_v1-2", + } + if _, err := NewEvidenceIngestedEvent("complytime-gateway", "my-app-v1", data); err != nil { + t.Errorf("unexpected error for valid subjectId: %v", err) + } +} + +func TestNewEvidenceSealedEvent(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "s3://evidence/abc123", + SubjectID: "my-app-v1", + } + + e, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data) + if err != nil { + t.Fatalf("NewEvidenceSealedEvent: %v", err) + } + + if e.Type() != TypeEvidenceSealed { + t.Errorf("Type() = %q, want %q", e.Type(), TypeEvidenceSealed) + } + if e.Subject() != "my-app-v1" { + t.Errorf("Subject() = %q, want %q", e.Subject(), "my-app-v1") + } + + var got EvidenceSealedData + if err := e.DataAs(&got); err != nil { + t.Fatalf("DataAs: %v", err) + } + if got.SubjectID != data.SubjectID { + t.Errorf("data.SubjectID = %q, want %q", got.SubjectID, data.SubjectID) + } + if got.StorageRef != data.StorageRef { + t.Errorf("data.StorageRef = %q, want %q", got.StorageRef, data.StorageRef) + } +} + +func TestNewEvidenceSealedEventEmptyStorageRef(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty storageRef") + } +} + +func TestNewEvidenceSealedEventInvalidStorageRef(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + StorageRef: "evidence-bucket/abc123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for storageRef without a URI scheme prefix") + } +} + +func TestNewEvidenceSealedEventInvalidSubjectID(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app.v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for invalid subjectId") + } +} + +func TestNewEvidenceSealedEventEmptyContentDigest(t *testing.T) { + data := EvidenceSealedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceSealedEventEmptyArtifactType(t *testing.T) { + data := EvidenceSealedData{ + ContentDigest: "sha256:abc123", + SubjectID: "my-app-v1", + } + if _, err := NewEvidenceSealedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + +func TestNewEvidenceQuarantinedEvent(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + + e, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data) + if err != nil { + t.Fatalf("NewEvidenceQuarantinedEvent: %v", err) + } + + if e.Type() != TypeEvidenceQuarantined { + t.Errorf("Type() = %q, want %q", e.Type(), TypeEvidenceQuarantined) + } + + var got EvidenceQuarantinedData + if err := e.DataAs(&got); err != nil { + t.Fatalf("DataAs: %v", err) + } + if got.Reason != data.Reason { + t.Errorf("data.Reason = %q, want %q", got.Reason, data.Reason) + } +} + +func TestNewEvidenceQuarantinedEventEmptyReason(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty reason") + } +} + +func TestNewEvidenceQuarantinedEventEmptyContentDigest(t *testing.T) { + data := EvidenceQuarantinedData{ + ArtifactType: "application/vnd.gemara.evaluation-log+json", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty contentDigest") + } +} + +func TestNewEvidenceQuarantinedEventEmptyArtifactType(t *testing.T) { + data := EvidenceQuarantinedData{ + ContentDigest: "sha256:abc123", + SubjectID: "my-app-v1", + Reason: "content digest mismatch", + } + if _, err := NewEvidenceQuarantinedEvent("complytime-worker", "my-app-v1", data); err == nil { + t.Error("expected error for empty artifactType") + } +} + func TestExamplePayloads_ConformToSchema(t *testing.T) { + schemasDir := filepath.Join("..", "api", "events", "schemas") examplesDir := filepath.Join("..", "api", "events", "examples") + + // Register every generated schema with the compiler under a stable + // in-memory URL. The relative $id in each schema resolves against its + // registration URL, so the CloudEvent envelope's cross-file $ref to its + // *Data schema (e.g. "EvidenceIngestedData.schema.json") resolves too. + const base = "mem:///" + schemaFiles, err := filepath.Glob(filepath.Join(schemasDir, "*.schema.json")) + if err != nil { + t.Fatalf("glob schemas: %v", err) + } + if len(schemaFiles) == 0 { + t.Fatal("no schema files found in api/events/schemas/") + } + compiler := jsonschema.NewCompiler() + for _, path := range schemaFiles { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read schema %s: %v", path, err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + t.Fatalf("parse schema %s: %v", path, err) + } + if err := compiler.AddResource(base+filepath.Base(path), doc); err != nil { + t.Fatalf("add schema %s: %v", path, err) + } + } + + // envelopeSchema maps a CloudEvents type to the envelope schema that + // validates a full example payload (envelope plus data) for that type. + envelopeSchema := map[string]string{ + TypeEvidenceIngested: "EvidenceIngestedCloudEvent.schema.json", + TypeEvidenceSealed: "EvidenceSealedCloudEvent.schema.json", + TypeEvidenceQuarantined: "EvidenceQuarantinedCloudEvent.schema.json", + } + files, err := filepath.Glob(filepath.Join(examplesDir, "*.json")) if err != nil { t.Fatalf("glob examples: %v", err) @@ -244,55 +464,29 @@ func TestExamplePayloads_ConformToSchema(t *testing.T) { t.Fatalf("read %s: %v", path, err) } - // Validate the full CloudEvents envelope deserializes. + // The CloudEvents type selects which envelope schema applies. var envelope struct { - SpecVersion string `json:"specversion"` - ID string `json:"id"` - Type string `json:"type"` - Source string `json:"source"` - Subject string `json:"subject"` - Time string `json:"time"` - DataContentType string `json:"datacontenttype"` - Data EvidenceIngestedData `json:"data"` + Type string `json:"type"` } if err := json.Unmarshal(b, &envelope); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - // CloudEvents envelope const values. - if envelope.SpecVersion != "1.0" { - t.Errorf("specversion = %q, want %q", envelope.SpecVersion, "1.0") + t.Fatalf("unmarshal type: %v", err) } - if envelope.Type != TypeEvidenceIngested { - t.Errorf("type = %q, want %q", envelope.Type, TypeEvidenceIngested) - } - if envelope.DataContentType != "application/json" { - t.Errorf("datacontenttype = %q, want %q", envelope.DataContentType, "application/json") + schemaFile, ok := envelopeSchema[envelope.Type] + if !ok { + t.Fatalf("unhandled example type %q — add it to envelopeSchema", envelope.Type) } - // Required envelope fields must be non-empty. - if envelope.ID == "" { - t.Error("id must not be empty") - } - if envelope.Source == "" { - t.Error("source must not be empty") - } - if envelope.Subject == "" { - t.Error("subject must not be empty") - } - if envelope.Time == "" { - t.Error("time must not be empty") + sch, err := compiler.Compile(base + schemaFile) + if err != nil { + t.Fatalf("compile %s: %v", schemaFile, err) } - // Required data fields must be non-empty. - if envelope.Data.ContentDigest == "" { - t.Error("data.contentDigest must not be empty") - } - if envelope.Data.ArtifactType == "" { - t.Error("data.artifactType must not be empty") + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(b)) + if err != nil { + t.Fatalf("parse example %s: %v", path, err) } - if envelope.Data.SubjectID == "" { - t.Error("data.subjectId must not be empty") + if err := sch.Validate(inst); err != nil { + t.Errorf("%s does not conform to %s:\n%v", filepath.Base(path), schemaFile, err) } }) } diff --git a/go.mod b/go.mod index 6df5f29..4cd4b6d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/complytime/complyapi -go 1.26.5 +go 1.26.6 require ( github.com/cloudevents/sdk-go/v2 v2.16.2 github.com/google/uuid v1.6.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 gopkg.in/yaml.v3 v3.0.1 ) @@ -13,5 +14,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index b578de5..1739e68 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -21,6 +23,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= @@ -31,8 +35,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=