diff --git a/module/contracts/accounts/saas.events.codefly.yaml b/module/contracts/accounts/saas.events.codefly.yaml index 65de25b29..13112f15b 100644 --- a/module/contracts/accounts/saas.events.codefly.yaml +++ b/module/contracts/accounts/saas.events.codefly.yaml @@ -267,6 +267,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.document.confirmed + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.document.dead_letter_redriven schema: saas/events/v1/events.proto#EventEnvelope visibility: external @@ -279,6 +283,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.document.extractor_revised + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.document.frozen schema: saas/events/v1/events.proto#EventEnvelope visibility: external @@ -319,6 +327,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.document.read_unscoped + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.document.renamed schema: saas/events/v1/events.proto#EventEnvelope visibility: external @@ -327,6 +339,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.document.search_unscoped + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.document.snapshot.committed schema: saas/events/v1/events.proto#EventEnvelope visibility: external diff --git a/module/deployment/generated/asyncapi.json b/module/deployment/generated/asyncapi.json index 3171ef3e1..4d283e53e 100644 --- a/module/deployment/generated/asyncapi.json +++ b/module/deployment/generated/asyncapi.json @@ -724,6 +724,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.document.confirmed": { + "address": "saas.document.confirmed", + "title": "saas.document.confirmed", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.document.confirmed" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.document.dead_letter_redriven": { "address": "saas.document.dead_letter_redriven", "title": "saas.document.dead_letter_redriven", @@ -757,6 +768,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.document.extractor_revised": { + "address": "saas.document.extractor_revised", + "title": "saas.document.extractor_revised", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.document.extractor_revised" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.document.frozen": { "address": "saas.document.frozen", "title": "saas.document.frozen", @@ -867,6 +889,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.document.read_unscoped": { + "address": "saas.document.read_unscoped", + "title": "saas.document.read_unscoped", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.document.read_unscoped" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.document.renamed": { "address": "saas.document.renamed", "title": "saas.document.renamed", @@ -889,6 +922,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.document.search_unscoped": { + "address": "saas.document.search_unscoped", + "title": "saas.document.search_unscoped", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.document.search_unscoped" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.document.snapshot.committed": { "address": "saas.document.snapshot.committed", "title": "saas.document.snapshot.committed", @@ -2511,6 +2555,13 @@ }, "title": "saas publishes saas.document.archived" }, + "send:saas.document.confirmed": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.document.confirmed" + }, + "title": "saas publishes saas.document.confirmed" + }, "send:saas.document.dead_letter_redriven": { "action": "send", "channel": { @@ -2532,6 +2583,13 @@ }, "title": "saas publishes saas.document.effect.committed" }, + "send:saas.document.extractor_revised": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.document.extractor_revised" + }, + "title": "saas publishes saas.document.extractor_revised" + }, "send:saas.document.frozen": { "action": "send", "channel": { @@ -2602,6 +2660,13 @@ }, "title": "saas publishes saas.document.read" }, + "send:saas.document.read_unscoped": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.document.read_unscoped" + }, + "title": "saas publishes saas.document.read_unscoped" + }, "send:saas.document.renamed": { "action": "send", "channel": { @@ -2616,6 +2681,13 @@ }, "title": "saas publishes saas.document.search" }, + "send:saas.document.search_unscoped": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.document.search_unscoped" + }, + "title": "saas publishes saas.document.search_unscoped" + }, "send:saas.document.snapshot.committed": { "action": "send", "channel": { @@ -3809,6 +3881,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.document.confirmed": { + "name": "saas.document.confirmed", + "title": "saas.document.confirmed", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.document.dead_letter_redriven": { "name": "saas.document.dead_letter_redriven", "title": "saas.document.dead_letter_redriven", @@ -3830,6 +3909,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.document.extractor_revised": { + "name": "saas.document.extractor_revised", + "title": "saas.document.extractor_revised", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.document.frozen": { "name": "saas.document.frozen", "title": "saas.document.frozen", @@ -3900,6 +3986,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.document.read_unscoped": { + "name": "saas.document.read_unscoped", + "title": "saas.document.read_unscoped", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.document.renamed": { "name": "saas.document.renamed", "title": "saas.document.renamed", @@ -3914,6 +4007,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.document.search_unscoped": { + "name": "saas.document.search_unscoped", + "title": "saas.document.search_unscoped", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.document.snapshot.committed": { "name": "saas.document.snapshot.committed", "title": "saas.document.snapshot.committed", diff --git a/module/deployment/generated/communication.md b/module/deployment/generated/communication.md index cb4a9672f..465dc99da 100644 --- a/module/deployment/generated/communication.md +++ b/module/deployment/generated/communication.md @@ -528,6 +528,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.document.confirmed + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.document.dead_letter_redriven - **Publisher:** saas @@ -552,6 +560,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.document.extractor_revised + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.document.frozen - **Publisher:** saas @@ -632,6 +648,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.document.read_unscoped + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.document.renamed - **Publisher:** saas @@ -648,6 +672,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.document.search_unscoped + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.document.snapshot.committed - **Publisher:** saas diff --git a/module/deployment/generated/event-catalog.json b/module/deployment/generated/event-catalog.json index 43f05380a..7b179359f 100644 --- a/module/deployment/generated/event-catalog.json +++ b/module/deployment/generated/event-catalog.json @@ -6504,6 +6504,106 @@ } ] }, + { + "type": "saas.document.confirmed", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.document.dead_letter_redriven", "namespace": "saas", @@ -6804,6 +6904,106 @@ } ] }, + { + "type": "saas.document.extractor_revised", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.document.frozen", "namespace": "saas", @@ -7804,6 +8004,106 @@ } ] }, + { + "type": "saas.document.read_unscoped", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.document.renamed", "namespace": "saas", @@ -8004,6 +8304,106 @@ } ] }, + { + "type": "saas.document.search_unscoped", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.document.snapshot.committed", "namespace": "saas", diff --git a/module/services/accounts/code/pkg/business/audit_parity_test.go b/module/services/accounts/code/pkg/business/audit_parity_test.go index 3171d1734..6aeaccbdc 100644 --- a/module/services/accounts/code/pkg/business/audit_parity_test.go +++ b/module/services/accounts/code/pkg/business/audit_parity_test.go @@ -46,6 +46,12 @@ var eventsEmittedOutsideRPC = []EventType{ EventDatasourceSourceAccessRestored, EventDatasourceSourceRecovered, EventDocumentArchived, + // A document module reports these through EmitAuditEvent: the `saas` + // namespace is the host's, so a module cannot declare them itself. + EventDocumentConfirmed, + EventDocumentExtractorRevised, + EventDocumentReadUnscoped, + EventDocumentSearchUnscoped, // A document module's operator redrive reports this through EmitAuditEvent. EventDocumentDeadLetterRedriven, EventDocumentDeleted, diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 5ac568517..af9e64d89 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -554,6 +554,24 @@ const ( // is who-did-what-to-which-resource (the entry is re-derived on an operator's // say-so), not pipeline bookkeeping, so it has a type of its own. EventDocumentDeadLetterRedriven EventType = "saas.document.dead_letter_redriven" + + // A document module's own facts, reported through the module-facing + // EmitAuditEvent. The `saas` namespace is the host's, so a module cannot + // declare a type here through DeclareAuditEventTypes: the host registers + // each one, with the shape the emitting module sends. + // + // A read or search that involved no collection: the caller could read none, + // or its request resolved to none. saas.document.read / search each record + // one collection and require its boundary, so without these the refusal and + // the empty answer reached only the producer's own log. + EventDocumentReadUnscoped EventType = "saas.document.read_unscoped" + EventDocumentSearchUnscoped EventType = "saas.document.search_unscoped" + // A person confirmed the value a reference reads at one version of a + // document: an append-only fact about that reference. + EventDocumentConfirmed EventType = "saas.document.confirmed" + // An extractor attached to a document was revised: attached, corrected or + // retired. + EventDocumentExtractorRevised EventType = "saas.document.extractor_revised" ) var auditEventCatalog = []AuditEventDefinition{ @@ -905,6 +923,65 @@ var auditEventCatalog = []AuditEventDefinition{ PayloadField{Name: "producer", Kind: FieldString, Required: true, MaxLen: 128}, PayloadField{Name: "error_class", Kind: FieldEnum, Required: true, Enum: []string{"permanent", "exhausted", "cancelled", "unknown"}})...))), + observation(EventDocumentReadUnscoped, CategoryAccess, "A document read that involved no collection ended with an explicit outcome: it was denied, found nothing the caller may read, or failed before any collection was resolved.", documentUnscopedReadFields...), + observation(EventDocumentSearchUnscoped, CategoryAccess, "A search that involved no collection ended with an explicit outcome: it was denied, the caller may read no collection it names, or it failed before any collection was resolved.", documentUnscopedReadFields...), + // A confirmation is recorded only when it commits: the module appends the + // event in the same transaction as its append-only confirmation row, and a + // refused confirmation writes nothing, so the type carries no `outcome`. The + // entry is the resource and the confirmation id is the idempotency key, so a + // retried emission collapses. The confirmed value is deliberately not a + // field: the spine records that a reference was confirmed, where, and by + // whom, never what it read. The version and every reference field are + // required: a confirmation that cannot say which version and which reference + // it confirmed is not one. `address` is the reference's position in its + // format's own notation, bounded in bytes like the module's request. + requiresIdempotencyKey(requiresEntry(mutation(EventDocumentConfirmed, CategoryLifecycle, + "A person confirmed the value a reference read at one version of a document.", + append(required(documentFields, "version"), + PayloadField{Name: "extractor", Kind: FieldString, Required: true}, + PayloadField{Name: "extractor_version", Kind: FieldString, Required: true}, + PayloadField{Name: "field", Kind: FieldString, Required: true}, + PayloadField{Name: "format", Kind: FieldString, Required: true}, + PayloadField{Name: "address", Kind: FieldString, Required: true, MaxLen: 1024})...))), + // The native extractor itself stays off the trail; its digest names it. + // Everything a revision always names is required: the version it was + // derived from, the key and revision, its digest, whether it retires the + // key, the native request type and what proposed it. `proposal_source` + // names the proposing run, receipt or derivation, but nothing bounds it to + // an identifier: it is an unvalidated string of up to 512 bytes, so it is + // marked PII and redacted from exports and webhooks. + requiresIdempotencyKey(requiresEntry(mutation(EventDocumentExtractorRevised, CategoryLifecycle, + "An extractor attached to a document was revised: attached, corrected or retired.", + append(required(documentFields, "version"), + PayloadField{Name: "extractor_key", Kind: FieldString, Required: true, MaxLen: 128}, + PayloadField{Name: "revision", Kind: FieldInt, Required: true}, + PayloadField{Name: "digest", Kind: FieldString, Required: true, MaxLen: 64}, + PayloadField{Name: "parent_digest", Kind: FieldString, MaxLen: 64}, + PayloadField{Name: "retired", Kind: FieldBool, Required: true}, + PayloadField{Name: "request_type", Kind: FieldString, Required: true, MaxLen: 128}, + PayloadField{Name: "proposal_kind", Kind: FieldString, Required: true, MaxLen: 16}, + pii(PayloadField{Name: "proposal_source", Kind: FieldString, MaxLen: 512}))...))), +} + +// documentUnscopedReadFields is the shape of a read or search that involved no +// collection. It is a type of its own, not saas.document.read with an optional +// boundary, because the registry declares each field's requirement on its own: +// "boundary is required unless the outcome is denied, empty or failed" is a +// conditional the registry and its JSON Schema projection cannot state, so it +// would be enforced nowhere. Here the rule is mechanical: `boundary` is not a +// field, so a payload naming a collection is refused as an unregistered field; +// `returned` is not an outcome, because evidence is always returned from some +// collection and belongs on the scoped type; and `result_count` is absent, +// because with no collection there is no result to count. A refusal is the +// outcome `denied`, never a type of its own. +var documentUnscopedReadFields = []PayloadField{ + str("solution"), + uid("actor_principal_id"), + uid("owner_principal_id"), + str("initiator"), + {Name: "correlation_id", Kind: FieldString, Required: true}, + {Name: "outcome", Kind: FieldEnum, Required: true, Enum: []string{"empty", "denied", "failed"}}, + {Name: "duration_ms", Kind: FieldInt}, } // webhookAdminVersion is version 2 of the webhook administration events: the diff --git a/module/services/accounts/code/pkg/business/audit_registry_test.go b/module/services/accounts/code/pkg/business/audit_registry_test.go index 4387ea657..47ec1d771 100644 --- a/module/services/accounts/code/pkg/business/audit_registry_test.go +++ b/module/services/accounts/code/pkg/business/audit_registry_test.go @@ -227,6 +227,85 @@ func TestValidatePayload_DocumentDeadLetterRedriven(t *testing.T) { require.NoError(t, ValidatePayload(EventDocumentDeadLetterRedriven, atBound)) } +// The document module's reserved types: each is registered with exactly the +// payload its emitter sends, because declared types only grow. +func TestValidatePayload_DocumentModuleReservedTypes(t *testing.T) { + t.Run("confirmed", func(t *testing.T) { + complete := func() map[string]any { + return map[string]any{ + "solution": "documents", "version": "v1", "initiator": "RecordConfirmation", + "extractor": "x", "extractor_version": "1", "field": "f", "format": "xlsx", "address": "Sheet1!A1", + } + } + require.NoError(t, ValidatePayload(EventDocumentConfirmed, complete())) + for _, field := range []string{"version", "extractor", "extractor_version", "field", "format", "address"} { + p := complete() + delete(p, field) + require.Errorf(t, ValidatePayload(EventDocumentConfirmed, p), "%s is required", field) + } + p := complete() + p["value"] = "42" + require.Error(t, ValidatePayload(EventDocumentConfirmed, p), "the confirmed value is never on the spine") + p = complete() + p["address"] = strings.Repeat("a", 1025) + require.Error(t, ValidatePayload(EventDocumentConfirmed, p), "address is bounded at 1024 bytes") + p["address"] = strings.Repeat("a", 1024) + require.NoError(t, ValidatePayload(EventDocumentConfirmed, p)) + require.True(t, AuditEventRequiresEntry(EventDocumentConfirmed)) + require.True(t, AuditEventRequiresIdempotencyKey(EventDocumentConfirmed)) + }) + t.Run("extractor_revised", func(t *testing.T) { + complete := func() map[string]any { + return map[string]any{ + "version": "v1", "extractor_key": "k", "revision": 2, "digest": "d", "parent_digest": "p", + "retired": false, "request_type": "t", "proposal_kind": "person", "proposal_source": "a note", + } + } + require.NoError(t, ValidatePayload(EventDocumentExtractorRevised, complete())) + p := complete() + delete(p, "parent_digest") + delete(p, "proposal_source") + require.NoError(t, ValidatePayload(EventDocumentExtractorRevised, p), "a first attachment has no parent and may name no source") + for _, field := range []string{"version", "extractor_key", "revision", "digest", "retired", "request_type", "proposal_kind"} { + p := complete() + delete(p, field) + require.Errorf(t, ValidatePayload(EventDocumentExtractorRevised, p), "%s is required", field) + } + p = complete() + p["proposal_source"] = strings.Repeat("s", 513) + require.Error(t, ValidatePayload(EventDocumentExtractorRevised, p), "proposal_source is bounded at 512 bytes") + require.True(t, AuditEventRequiresEntry(EventDocumentExtractorRevised)) + require.True(t, AuditEventRequiresIdempotencyKey(EventDocumentExtractorRevised)) + def, ok := LookupAuditEvent(EventDocumentExtractorRevised) + require.True(t, ok) + for _, f := range def.Fields { + require.Equalf(t, f.Name == "proposal_source", f.PII, "PII mark of %s", f.Name) + } + }) + t.Run("unscoped reads", func(t *testing.T) { + for _, event := range []EventType{EventDocumentReadUnscoped, EventDocumentSearchUnscoped} { + complete := func() map[string]any { + return map[string]any{"solution": "documents", "correlation_id": "c1", "outcome": "empty", "duration_ms": 3} + } + require.NoError(t, ValidatePayload(event, complete())) + for _, outcome := range []string{"empty", "denied", "failed"} { + p := complete() + p["outcome"] = outcome + require.NoErrorf(t, ValidatePayload(event, p), "outcome %q", outcome) + } + p := complete() + p["outcome"] = "returned" + require.Errorf(t, ValidatePayload(event, p), "%s: returned belongs on the scoped type", event) + p = complete() + p["boundary"] = "b" + require.Errorf(t, ValidatePayload(event, p), "%s: no collection, no boundary", event) + p = complete() + delete(p, "correlation_id") + require.Errorf(t, ValidatePayload(event, p), "%s: correlation_id is required", event) + } + }) +} + // The nonempty rule belongs to the declaration, not to a list of the event types // somebody remembered to add: a required string-valued field on ANY registered // type refuses a blank, and a required bool or int still records false and 0. diff --git a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go index f91b0b386..68c78da8f 100644 --- a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go +++ b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go @@ -90,9 +90,11 @@ var published = [...]PublishedEvent{ {Type: "saas.delegation.denied", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.delegation.requested", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.archived", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.document.confirmed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.dead_letter_redriven", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.deleted", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.effect.committed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.document.extractor_revised", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.frozen", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.ingest_skipped_stale", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.ingested", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, @@ -103,8 +105,10 @@ var published = [...]PublishedEvent{ {Type: "saas.document.quarantine_released", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.quarantined", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.read", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.document.read_unscoped", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.renamed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.search", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.document.search_unscoped", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.snapshot.committed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.snapshot.skipped_stale", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.document.subscribed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"},