lib/smplkit/audit/models.rb



# frozen_string_literal: true

module Smplkit
  module Audit
    # Wrap a generated-audit-API call and translate +ApiError+ into the
    # +Smplkit::Error+ hierarchy. Connection-level failures (no
    # response code) become {Smplkit::ConnectionError}; status-coded
    # failures route through {Smplkit::Errors.raise_for_status}, which
    # emits +PaymentRequiredError+ / +NotFoundError+ / +ConflictError+
    # / +ValidationError+ / +Error+ depending on the JSON:API body.
    def self.call_api
      yield
    rescue SmplkitGeneratedClient::Audit::ApiError => e
      raise Smplkit::ConnectionError, e.message.to_s if e.code.nil? || e.code.zero?

      Smplkit::Errors.raise_for_status(e.code, e.response_body.to_s)
      # raise_for_status only returns on 2xx; if we get here the
      # generated layer raised on a 2xx (shouldn't happen) — re-raise
      # the original so the caller can inspect.
      raise
    end

    # Parse the +page[after]+ cursor out of a JSON:API +links.next+
    # URL. Returns nil for non-string input or when the link carries
    # no cursor parameter; trims trailing query params at the next
    # ampersand so they don't leak into the token.
    def self.next_cursor(link)
      return nil unless link.is_a?(String)

      idx = link.index("page[after]=")
      return nil if idx.nil?

      token = link[(idx + "page[after]=".length)..]
      amp = token.index("&")
      amp ? token[0...amp] : token
    end

    # Pull the offset-pagination block out of a JSON:API +meta+ envelope.
    # Returns a hash with +:page+/+:size+ (and +:total+/+:total_pages+ when
    # the request opted into +meta[total]=true+). Always returns a hash so
    # callers don't have to nil-check before reading individual keys.
    def self.extract_pagination(meta)
      pagination = meta&.pagination
      return {} if pagination.nil?

      out = { page: pagination.page, size: pagination.size }
      out[:total] = pagination.total unless pagination.total.nil?
      out[:total_pages] = pagination.total_pages unless pagination.total_pages.nil?
      out
    end

    # Supported SIEM forwarder destination types (ADR-047 §2.12).
    #
    # Members are declared in alphabetical order. Customers pass these
    # constants — or the equivalent string — to the management
    # +forwarders+ surface; the wrapper validates membership via {coerce}
    # before round-tripping to the wire.
    module ForwarderType
      DATADOG = "DATADOG"
      ELASTIC = "ELASTIC"
      HONEYCOMB = "HONEYCOMB"
      HTTP = "HTTP"
      NEW_RELIC = "NEW_RELIC"
      SPLUNK_HEC = "SPLUNK_HEC"
      SUMO_LOGIC = "SUMO_LOGIC"

      VALUES = [DATADOG, ELASTIC, HONEYCOMB, HTTP, NEW_RELIC, SPLUNK_HEC, SUMO_LOGIC].freeze

      # Validate and normalize an input to a wire-format string.
      #
      # @param value [String, nil] a published constant or its literal string.
      # @return [String, nil] the canonical wire value (or +nil+ when input is +nil+).
      # @raise [ArgumentError] when +value+ is not a member of {VALUES}.
      def self.coerce(value)
        return nil if value.nil?

        s = value.to_s
        return s if VALUES.include?(s)

        raise ArgumentError,
              "Unknown ForwarderType #{value.inspect}; expected one of #{VALUES.inspect}"
      end
    end

    # HTTP verb used by a forwarder's outbound delivery (ADR-047 §2.12).
    #
    # Mirrors the audit spec's +HttpConfigurationMethod+ enum so the
    # +HttpConfiguration#method+ field is constrained to a known value
    # instead of accepting any string. Members are declared in
    # alphabetical order.
    module HttpMethod
      DELETE = "DELETE"
      GET = "GET"
      PATCH = "PATCH"
      POST = "POST"
      PUT = "PUT"

      VALUES = [DELETE, GET, PATCH, POST, PUT].freeze

      # Validate and normalize an input to a wire-format string.
      #
      # @param value [String, nil] a published constant or its literal string.
      # @return [String, nil] the canonical wire value (or +nil+ when input is +nil+).
      # @raise [ArgumentError] when +value+ is not a member of {VALUES}.
      def self.coerce(value)
        return nil if value.nil?

        s = value.to_s
        return s if VALUES.include?(s)

        raise ArgumentError,
              "Unknown HttpMethod #{value.inspect}; expected one of #{VALUES.inspect}"
      end
    end

    # Engine that evaluates a forwarder's +transform+ template
    # (ADR-047 §2.12). Only +JSONATA+ is supported today; the enum
    # exists so the field is typed instead of accepting any string,
    # and so additional engines can be added without breaking the
    # public surface.
    module TransformType
      JSONATA = "JSONATA"

      VALUES = [JSONATA].freeze

      # Validate and normalize an input to a wire-format string.
      #
      # @param value [String, nil] a published constant or its literal string.
      # @return [String, nil] the canonical wire value (or +nil+ when input is +nil+).
      # @raise [ArgumentError] when +value+ is not a member of {VALUES}.
      def self.coerce(value)
        return nil if value.nil?

        s = value.to_s
        return s if VALUES.include?(s)

        raise ArgumentError,
              "Unknown TransformType #{value.inspect}; expected one of #{VALUES.inspect}"
      end
    end

    # A single audit event as returned by the audit service (ADR-047 §2.3.1).
    #
    # @!attribute [rw] id
    #   @return [String] Server-assigned UUID for this event.
    # @!attribute [rw] action
    #   @return [String] Action slug — e.g. +"user.created"+, +"invoice.paid"+.
    # @!attribute [rw] resource_type
    #   @return [String] Type of resource the action operated on — e.g. +"invoice"+.
    # @!attribute [rw] resource_id
    #   @return [String] Customer-facing id of the resource the action operated on.
    # @!attribute [rw] occurred_at
    #   @return [String] ISO-8601 timestamp of when the action happened, as reported by the source.
    # @!attribute [rw] created_at
    #   @return [String] ISO-8601 timestamp of when the audit service first ingested this event.
    # @!attribute [rw] actor_type
    #   @return [String, nil] Customer-supplied free-form actor type string — +nil+ when not provided.
    # @!attribute [rw] actor_id
    #   @return [String, nil] Customer-supplied free-form actor identifier — +nil+ when not provided.
    # @!attribute [rw] actor_label
    #   @return [String, nil] Customer-supplied display label for the actor — typically a name or email.
    # @!attribute [rw] data
    #   @return [Hash{String => Object}] Free-form per-event payload defined by the customer.
    # @!attribute [rw] idempotency_key
    #   @return [String, nil] Customer-supplied dedupe key, +nil+ if not provided.
    # @!attribute [rw] do_not_forward
    #   @return [Boolean] When +true+, skip SIEM forwarder delivery regardless of any matching filter.
    AuditEvent = Struct.new(
      :id, :action, :resource_type, :resource_id,
      :occurred_at, :created_at,
      :actor_type, :actor_id, :actor_label,
      :data, :idempotency_key, :do_not_forward,
      keyword_init: true
    ) do
      def self.from_resource(resource)
        attrs = resource.attributes
        new(
          id: resource.id,
          action: attrs.action,
          resource_type: attrs.resource_type,
          resource_id: attrs.resource_id,
          occurred_at: attrs.occurred_at,
          created_at: attrs.created_at,
          actor_type: attrs.actor_type,
          actor_id: attrs.actor_id,
          actor_label: attrs.actor_label,
          data: Smplkit::Helpers.deep_stringify_keys(attrs.data || {}),
          idempotency_key: attrs.idempotency_key,
          do_not_forward: attrs.do_not_forward || false
        )
      end
    end

    # A distinct +resource_type+ slug seen for the account.
    #
    # The +id+ and +resource_type+ are the same value — JSON:API surfaces
    # the customer-facing key as the resource id (ADR-014). The duplication
    # keeps SDK consumers from having to dig into the id field when
    # filtering UI controls; pick whichever name reads better in context.
    #
    # @!attribute [rw] id
    #   @return [String] JSON:API resource id (same as +resource_type+).
    # @!attribute [rw] resource_type
    #   @return [String] The distinct resource_type slug.
    # @!attribute [rw] created_at
    #   @return [String] ISO-8601 timestamp of the earliest sighting for this slug.
    ResourceType = Struct.new(:id, :resource_type, :created_at, keyword_init: true) do
      def self.from_resource(resource)
        attrs = resource.attributes
        new(
          id: resource.id,
          resource_type: attrs.resource_type || resource.id,
          created_at: attrs.created_at
        )
      end
    end

    # A distinct +action+ slug seen for the account.
    #
    # Same shape as {ResourceType} — +id+ and +action+ are the same value.
    # +created_at+ is the earliest sighting; when the parent list call
    # filtered by +resource_type+, this is the first sighting of that
    # specific (action, resource_type) triple, not the action overall.
    #
    # @!attribute [rw] id
    #   @return [String] JSON:API resource id (same as +action+).
    # @!attribute [rw] action
    #   @return [String] The distinct action slug.
    # @!attribute [rw] created_at
    #   @return [String] ISO-8601 timestamp of the earliest sighting for this slug.
    Action = Struct.new(:id, :action, :created_at, keyword_init: true) do
      def self.from_resource(resource)
        attrs = resource.attributes
        new(
          id: resource.id,
          action: attrs.action || resource.id,
          created_at: attrs.created_at
        )
      end
    end

    # A single name/value HTTP header on a forwarder destination.
    #
    # @!attribute [rw] name
    #   @return [String] Header name (e.g. +"Authorization"+, +"DD-API-KEY"+).
    # @!attribute [rw] value
    #   @return [String] Header value, plaintext on writes. The audit service
    #     encrypts values at rest; reads return them as +"<redacted>"+.
    HttpHeader = Struct.new(:name, :value, keyword_init: true)

    # Forwarder destination HTTP request shape.
    #
    # @!attribute [rw] method
    #   @return [String] HTTP verb used for delivery. Defaults to {HttpMethod::POST}.
    # @!attribute [rw] url
    #   @return [String] Destination URL the audit service sends each event to.
    # @!attribute [rw] headers
    #   @return [Array<HttpHeader>] Headers attached to every outbound request.
    #     Values carry credentials and are encrypted at rest server-side; reads
    #     return them redacted.
    # @!attribute [rw] success_status
    #   @return [String] Status the destination must return for delivery to count
    #     as success — an exact code (+"200"+, +"204"+) or a class (+"2xx"+, +"4xx"+).
    #     Defaults to +"2xx"+.
    #
    # rubocop:disable Lint/StructNewOverride -- ``:method`` matches the
    # API attribute and shadowing Struct#method is the expected ergonomics.
    HttpConfiguration = Struct.new(:method, :url, :headers, :success_status, keyword_init: true) do
      def initialize(method: HttpMethod::POST, url: "", headers: nil, success_status: "2xx")
        super(method: HttpMethod.coerce(method), url: url, headers: headers || [], success_status: success_status)
      end

      def self.to_wire(src)
        h = src.is_a?(Hash) ? new(**src) : src
        SmplkitGeneratedClient::Audit::HttpConfiguration.new(
          method: HttpMethod.coerce(h.method),
          url: h.url,
          headers: (h.headers || []).map do |hdr|
            name, value = if hdr.is_a?(Hash)
                            [hdr[:name] || hdr["name"],
                             hdr[:value] || hdr["value"]]
                          else
                            [hdr.name, hdr.value]
                          end
            SmplkitGeneratedClient::Audit::HttpHeader.new(name: name, value: value)
          end,
          success_status: h.success_status
        )
      end

      def self.from_wire(src)
        return new if src.nil?

        new(
          method: src.method || HttpMethod::POST,
          url: src.url || "",
          headers: (src.headers || []).map { |h| HttpHeader.new(name: h.name, value: h.value) },
          success_status: src.success_status || "2xx"
        )
      end
    end
    # rubocop:enable Lint/StructNewOverride

    # A SIEM streaming forwarder configured on the customer's account.
    #
    # Active-record style: instantiate via
    # +mgmt.audit.forwarders.new_forwarder(...)+, mutate fields directly,
    # and call {#save} to persist or {#delete} to remove. Header values in
    # +configuration.headers+ are returned redacted on reads — the GET path
    # on the audit API replaces every header value with +"<redacted>"+.
    # Re-supply real values before calling {#save}; the SDK does not cache
    # them client-side.
    class Forwarder
      # @return [String, nil] Server-assigned UUID, +nil+ until {#save} has run.
      attr_accessor :id

      # @return [String] Display name. Free-form.
      attr_accessor :name

      # @return [String] One of {ForwarderType::VALUES}.
      attr_accessor :forwarder_type

      # @return [Boolean] When +false+, the audit service skips delivery for
      #   this forwarder but still records +filtered_out+ deliveries.
      attr_accessor :enabled

      # @return [HttpConfiguration] Destination request configuration.
      attr_accessor :configuration

      # @return [String, nil] Optional free-text description.
      attr_accessor :description

      # @return [Hash, nil] Optional JSON Logic expression evaluated per event.
      #   When set, events that don't match are recorded as +filtered_out+
      #   deliveries instead of being delivered to the destination.
      attr_accessor :filter

      # @return [Object, nil] Optional template applied to each event before
      #   delivery. Free-form — the audit service passes the value verbatim to
      #   the engine named by {#transform_type}. For +TransformType::JSONATA+ a
      #   JSONata expression string; +nil+ delivers the event JSON as-is. Must
      #   be paired with a non-nil {#transform_type}.
      attr_accessor :transform

      # @return [String, nil] Engine that evaluates {#transform} — one of
      #   {TransformType::VALUES}. Required whenever {#transform} is set.
      attr_accessor :transform_type

      # @return [String, nil] ISO-8601 timestamp of first persist. +nil+ for an unsaved instance.
      attr_accessor :created_at

      # @return [String, nil] ISO-8601 timestamp of the most recent mutation.
      attr_accessor :updated_at

      # @return [String, nil] Soft-delete timestamp. +nil+ for live forwarders.
      attr_accessor :deleted_at

      # @return [Integer, nil] Monotonic version counter, bumped on every server-side write.
      attr_accessor :version

      def initialize(client = nil, name:, forwarder_type:, configuration:,
                     id: nil, enabled: true, description: nil,
                     filter: nil, transform: nil, transform_type: nil,
                     created_at: nil, updated_at: nil, deleted_at: nil, version: nil)
        @client = client
        @id = id
        @name = name
        @forwarder_type = ForwarderType.coerce(forwarder_type)
        @configuration = configuration
        @enabled = enabled
        @description = description
        @filter = filter
        @transform = transform
        @transform_type = TransformType.coerce(transform_type)
        @created_at = created_at
        @updated_at = updated_at
        @deleted_at = deleted_at
        @version = version
      end

      # Create or update this forwarder on the server.
      #
      # Upsert behavior is driven by {#created_at}: a forwarder with no
      # +created_at+ is created (POST); otherwise it's full-replace updated
      # (PUT). After the call, every field is refreshed from the server
      # response (including newly-assigned +id+, +created_at+, +updated_at+,
      # +version+).
      #
      # @raise [ArgumentError] when {#transform} and {#transform_type} are not
      #   both nil or both set, or when {#transform_type} is +JSONATA+ and
      #   {#transform} is not a +String+.
      # @return [self]
      def save
        raise "Forwarder was constructed without a client; cannot save" if @client.nil?

        self.class.send(:validate_transform_pair!, @transform, @transform_type)
        updated = @created_at.nil? ? @client._create_forwarder(self) : @client._update_forwarder(self)
        _apply(updated)
        self
      end
      alias save! save

      # Soft-delete this forwarder on the server.
      #
      # @return [nil]
      def delete
        raise "Forwarder was constructed without a client or id; cannot delete" if @client.nil? || @id.nil?

        @client.delete(@id)
      end
      alias delete! delete

      # @api private
      def _apply(other)
        @id = other.id
        @name = other.name
        @forwarder_type = other.forwarder_type
        @configuration = other.configuration
        @enabled = other.enabled
        @description = other.description
        @filter = other.filter
        @transform = other.transform
        @transform_type = other.transform_type
        @created_at = other.created_at
        @updated_at = other.updated_at
        @deleted_at = other.deleted_at
        @version = other.version
      end

      # Validate the +(transform, transform_type)+ pair.
      #
      # Both must be nil or both must be set. When +transform_type+ is
      # +TransformType::JSONATA+, +transform+ must be a +String+ (the
      # JSONata expression). Other engines accept any value.
      #
      # @api private
      def self.validate_transform_pair!(transform, transform_type)
        if transform.nil? != transform_type.nil?
          raise ArgumentError,
                "transform and transform_type must be specified together (both nil or both set)"
        end
        return if transform.nil?
        return unless transform_type == TransformType::JSONATA && !transform.is_a?(String)

        raise ArgumentError,
              "transform must be a String when transform_type is JSONATA " \
              "(got #{transform.class})"
      end

      def self.from_resource(resource, client: nil)
        a = resource.attributes
        new(
          client,
          id: resource.id,
          name: a.name,
          description: a.description,
          forwarder_type: a.forwarder_type,
          enabled: a.enabled.nil? || a.enabled,
          filter: a.filter.nil? ? nil : Smplkit::Helpers.deep_stringify_keys(a.filter),
          transform_type: a.transform_type,
          transform: a.transform,
          configuration: HttpConfiguration.from_wire(a.configuration),
          created_at: a.created_at,
          updated_at: a.updated_at,
          deleted_at: a.deleted_at,
          version: a.version
        )
      end
    end
  end
end