Namespace filter limit of 5 can be exceeded via the create mutation

The 5 namespace filter per destination limit is enforced only by no_more_than_5_namespace_filters? in AuditEvents::ExternallyStreamable, which runs when the destination is saved. Mutations::AuditEvents::Group::NamespaceFilters::Create (and the instance equivalent) builds and saves the NamespaceFilter record directly, so the destination is never validated and the cap is not applied. The filter model has no count validation, does not include Limitable, and there is no database constraint. The frontend guard in ee/app/assets/javascripts/audit_events/constants.js is UX only.

Note that Limitable on AuditEvents::Group::ExternalStreamingDestination uses limit_name = 'external_audit_event_destinations', which caps destinations per group. It does not apply to namespace filters.

Steps to reproduce

  1. On an Ultimate instance, take a top-level group with Owner access, and create six subgroups under it (parent/sub-1 through parent/sub-6).

  2. Create a group-level external streaming destination for the parent group (Secure > Audit events > Streams, or the auditEventsGroupDestinationCreate mutation).

  3. Run the following mutation six times, once per subgroup path. Bypass the UI and call GraphQL directly, since the frontend blocks the sixth request client-side:

    mutation {
      auditEventsGroupDestinationNamespaceFilterCreate(input: {
        destinationId: "gid://gitlab/AuditEvents::Group::ExternalStreamingDestination/<ID>",
        namespacePath: "parent/sub-N"
      }) {
        errors
        namespaceFilter { id }
      }
    }
  4. All six calls return an empty errors array and a persisted filter.

Expected behaviour

The sixth call fails with Namespace filters are limited to 5 per destination.

Actual behaviour

Six filters are persisted. Confirm with AuditEvents::Group::NamespaceFilter.where(external_streaming_destination_id: <ID>).count => 6.

Knock-on effect

The destination is now invalid. Any later save of it (for example renaming it) fails with the limit error until filters are deleted.

Possible fixes

  • Move the count check onto the filter model as a create-time validation, or
  • Have the mutation validate the destination before saving.

A unique-index-based approach will not work for a row count cap.

References

Found while reviewing !250059 (merged) (note).