#############################################################################
# File Name: appsentinels-envoy-istorio-gateway-filter.yaml
# Description: Sample Istio gateway lua filter for sending logs and enforcement
# info to edge controller over cluster ext_authz-http-service. Please change
# address and port value accordingly
# 
# Copyright (c) 2024 AppSentinels Inc
# All rights reserved.
# 
# This source code is the property of AppSentinels Inc and may not be used,
# copied, or distributed without prior written permission from AppSentinels.
# NOTICE: All information contained herein is, and remains the property
# of AppSentinels. The intellectual and technical concepts contained
# herein are proprietary to AppSentinels and may be covered by U.S. and
# Foreign Patents, patents in process, and are protected by trade secret
# or copyright law. Dissemination of this information or reproduction
# of this material is strictly forbidden unless prior written permission
# is obtained from AppSeentinels.
# Author: AppSentinels
# Creation Date: June 2024
# Version: 1.0.1
#############################################################################

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: request-response-filter
  namespace: istio-system
spec:
  configPatches:
  - applyTo: CLUSTER
    match:
      context: GATEWAY
    patch:
      operation: ADD
      value: # cluster specification
        name: "ext_authz-http-service"
        connect_timeout: 0.25s
        type: STRICT_DNS
        respect_dns_ttl: true
        dns_refresh_rate: 50s
        lb_policy: ROUND_ROBIN
        http2_protocol_options: {}
        per_connection_buffer_limit_bytes: 104857600
        load_assignment:
          cluster_name: ext_authz-http-service
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: <IP/hostname of edge controller>
                    port_value: 9002
  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
            subFilter:
              name: "envoy.filters.http.router"
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.filters.http.lua
        typed_config:
          "@type": "type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua"
          inline_code: |
            -- Appsentinels Envoy Filter in Lua

            -- Needed for visibility, pls update this to your filter instance name
            local INSTANCE_NAME = "unknown"
            -- End of visibility
            
            -- Config section start
            local ENFORCEMENT = false
            local AUTHZ_TIMEOUT = 100
            local ACCESSLOG_TIMEOUT = 100
            local FAILOPEN = true
            local CONTROLLER_CLUSTER_NAME = "ext_authz-http-service"
            local SKIP_METHODS = {"HEAD", "OPTIONS"}
            local LOGGING_CONTENT_TYPES = {"json", "form", "xml", "graphql"}
            local MAX_PAYLOAD_SIZE = 131072  --- 128k
            local AS_DEBUG = false
            -- Config section end

            local HOSTNAME = os.getenv("HOSTNAME")

            -- Helper functions start --
            function _bypass_method(method)
              if method == nil then
                return true
              end
              for _, v in ipairs(SKIP_METHODS) do
                --- do a substring match to check if v is in method
                ---
                --- This is a safe way to check if v is in SKIP_METHODS
                ---
                if string.find(v, method) then
                  return true
                end
              end
              return false
            end

            function _valid_content_type(content_type)
              if content_type == nil then
                return true
              end

              for _, v in ipairs(LOGGING_CONTENT_TYPES) do
                if string.find(content_type, v) then
                  return true
                end
              end
              return false
            end

            function _content_too_large(payload_size)
              if payload_size == nil then
                return false
              end

              if tonumber(payload_size) >= MAX_PAYLOAD_SIZE then
                return true
              else
                return false
              end
            end

            -- Helper functions end --

            function appsentinels_on_request(request_handle)
                -- Create a new table to store headers for httpCall
                local httpCallHeaders = {}

                -- Get the HTTP method and URI from the request headers
                local method = request_handle:headers():get(":method")
                if _bypass_method(method) then
                  if AS_DEBUG then request_handle:logInfo("as: bypassing unsupported method: " .. method) end
                  -- Pass this message over to response handler so it can bypass this log as well
                  request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.skip", true)
                  return
                end

                request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.method", method)

                -- Get all headers from request_handle
                for name, value in pairs(request_handle:headers()) do
                  httpCallHeaders[name] = value
                end

                httpCallHeaders["X-As-Error"] = ""

                -- Pass some info must for resp via metadata
                local metadata = request_handle:streamInfo():dynamicMetadata()
                local rid = httpCallHeaders["x-request-id"]
                if rid ~= nil then
                  request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.x-request-id", rid)
                else
                  httpCallHeaders["X-As-Error"] = "Missing X-Request-Id;"
                end

                local path = request_handle:headers():get(":path")
                if path ~= nil then
                  request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.path", path)
                else
                  httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing path;"
                end

                local host = request_handle:headers():get(":authority")
                if host ~= nil then
                  request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.authority", host)
                else
                  httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing host;"
                end

                local _start_time = request_handle:timestamp(EnvoyTimestampResolution.MILLISECOND)
                httpCallHeaders["X-As-Req-Timestamp"] = _start_time
                request_handle:streamInfo():dynamicMetadata():set("envoy.filters.http.lua", "as.req-time", _start_time)

                -- No error case
                if httpCallHeaders["X-As-Error"] == "" then
                  -- delete this key
                  httpCallHeaders["X-As-Error"] = nil
                end

                httpCallHeaders["connection"] = nil
                httpCallHeaders["keep-alive"] = nil

                -- Cannot send transfer-encoding header, this is relevant only for upstream to downstream connection
                if httpCallHeaders["transfer-encoding"] ~= nil then
                  httpCallHeaders["X-As-TE"] = httpCallHeaders["transfer-encoding"]
                  httpCallHeaders["transfer-encoding"] = nil
                end
              
                local _body_chunks = {}
                if _content_too_large(request_handle:headers():get("content-length")) or not _valid_content_type(httpCallHeaders["content-type"]) then
                  if AS_DEBUG then request_handle:logInfo("as: request body too large or unsupported content type") end

                  --- Setting this will ensure edge controller understands the payload being too large
                  httpCallHeaders["x-as-req-truncated-body"] = "true"
                else
                  local _total_len = 0
                  for chunk in request_handle:bodyChunks() do
                    local len = chunk:length()
                    if len > 0 then
                      local result = chunk:getBytes(0, len)
                      table.insert(_body_chunks, result)
                      _total_len = _total_len + len
                    end

                    if _total_len > MAX_PAYLOAD_SIZE then
                      if AS_DEBUG then request_handle:logInfo("as: request body too large") end
                      _body_chunks = {}
                      httpCallHeaders["x-as-req-truncated-body"] = "true"
                      break
                    end
                  end
                end

                -- dont do asynch for enforcement mode
                local asynchronous = not ENFORCEMENT

                -- https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter
                local authz_headers, authz_body = request_handle:httpCall(
                  CONTROLLER_CLUSTER_NAME,
                  httpCallHeaders,
                  table.concat(_body_chunks),
                  AUTHZ_TIMEOUT,
                  asynchronous)

                if ENFORCEMENT then
                  local authz_status = authz_headers[":status"]
                  authz_status = tonumber(authz_status)
                  if FAILOPEN then
                    -- 4xx for failopen case only
                    if authz_status > 399 and authz_status < 500 then
                      request_handle:respond(authz_headers, authz_body)
                    end
                  else
                    if authz_status > 399 and authz_status < 600 then
                      request_handle:respond(authz_headers, authz_body)
                    end
                  end
                end

            -- end of function appsentinels_on_request
            end

            local function append_query_param(existing_path, param_name, param_value)
                if string.find(existing_path, "?") then
                    -- If there's already a query, append with &
                    return string.format("%s&%s=%s", existing_path, param_name, param_value)
                else
                    -- If there's no query yet, start with ?
                    return string.format("%s?%s=%s", existing_path, param_name, param_value)
                end
            end

            function appsentinels_on_response(response_handle)
                local _skip = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.skip"]
                if _skip ~= nil and _skip == true then
                  if AS_DEBUG then response_handle:logDebug("as: Skipping resp logging, because req bypassed") end
                  return
                end

                -- Create a new table to store headers for httpCall
                local httpCallHeaders = {}

                -- Get all headers from response_handle
                for name, value in pairs(response_handle:headers()) do
                  -- skip headers starting with : , example :status
                  if string.sub(name, 1, 1) == ":" then
                    -- response_handle:logErr("as: resp header not logging " .. name .. ": " .. value)
                  else
                    httpCallHeaders[name] = value
                    -- response_handle:logErr("as: resp header name " .. name .. ": " .. value)
                  end
                end

                local rid = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.x-request-id"]
                if rid ~= nil then
                  httpCallHeaders["x-request-id"] = rid
                else
                  if AS_DEBUG then response_handle:logErr("as: Resp: Missing x-request-id") end
                end
                
                -- For httpCall to work, we need :method, :path and "authority are must", in error cases, we must populate something
                httpCallHeaders["X-As-Error"] = ""
                local method = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.method"]
                if method ~= nil then
                    httpCallHeaders[":method"] = method
                else
                    httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing method;"
                end

                local path = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.path"]
                if path ~= nil then
                    -- path can contain query
                    local full_path = append_query_param(path, "sensor", "envoy-lua")
                    full_path = append_query_param(full_path, "cap", "nohb")
                    httpCallHeaders[":path"] = full_path
                else
                    httpCallHeaders[":path"] = "/"
                    httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing path;"
                end

                local host = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.authority"]
                if host ~= nil then
                    httpCallHeaders[":authority"] = host
                else
                    httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing host;"
                end

                httpCallHeaders["X-As-Req-Timestamp"] = response_handle:streamInfo():dynamicMetadata():get("envoy.filters.http.lua")["as.req-time"]
                httpCallHeaders["X-As-Resp-Timestamp"] = response_handle:timestamp(EnvoyTimestampResolution.MILLISECOND)

                local status = response_handle:headers():get(":status")
                if status ~= nil then
                    httpCallHeaders["X-As-Status"] = status
                else
                    httpCallHeaders["X-As-Error"] = httpCallHeaders["X-As-Error"] .. "Missing status;"
                    -- What else to put here?
                    httpCallHeaders["X-As-Status"] = "200"
                end

                httpCallHeaders["X-As-Sensor"] = "envoy-http-access"
                httpCallHeaders["X-As-Sensor-Instance"] = INSTANCE_NAME
                httpCallHeaders["X-As-Sensor-Hostname"] = HOSTNAME
                
                -- No error case
                if httpCallHeaders["X-As-Error"] == "" then
                    -- delete this key
                    httpCallHeaders["X-As-Error"] = nil
                end

                httpCallHeaders["connection"] = nil
                httpCallHeaders["keep-alive"] = nil

                -- Cannot send transfer-encoding header, this is relevant only for upstream to downstream connection
                if httpCallHeaders["transfer-encoding"] ~= nil then
                    httpCallHeaders["X-As-TE"] = httpCallHeaders["transfer-encoding"]
                    httpCallHeaders["transfer-encoding"] = nil
                end

                local _body_chunks = {}
                if _content_too_large(response_handle:headers():get("content-length")) or not _valid_content_type(httpCallHeaders["content-type"]) then
                  if AS_DEBUG then response_handle:logInfo("as: resp body too large or unsupported content type") end
                  httpCallHeaders["x-as-resp-truncated-body"] = "true"
                else
                  local _total_len = 0
                  for chunk in response_handle:bodyChunks() do
                    local len = chunk:length()
                    if len > 0 then
                      local result = chunk:getBytes(0, len)
                      table.insert(_body_chunks, result)
                      _total_len = _total_len + len
                    end

                    if _total_len > MAX_PAYLOAD_SIZE then
                      if AS_DEBUG then response_handle:logInfo("as: resp body too large") end
                      _body_chunks = {}
                      httpCallHeaders["x-as-resp-truncated-body"] = "true"
                      break
                    end
                  end
                end

                local authz_headers, authz_body = response_handle:httpCall(
                  CONTROLLER_CLUSTER_NAME,
                  httpCallHeaders,
                  table.concat(_body_chunks),
                  ACCESSLOG_TIMEOUT,
                  true)
            end

            function envoy_on_request(request_handle)
                appsentinels_on_request(request_handle)
            end
            function envoy_on_response(response_handle)
                appsentinels_on_response(response_handle)
            end