Skip to main content

5 dicas para usar a linguagem Rego com o Open Policy Agent (OPA)

header cloud security

18 de setembro de 2020

0 minutos de leitura

Na Fugue, gostamos bastante do Open Policy Agent (OPA) e escrevemos muito código Rego para manter os recursos na nuvem seguros. Por isso, reunimos as lições mais valiosas que aprendemos nesse processo. Você também pode usar OPA e a linguagem Rego para implementar políticas como código e aplicar políticas codificadas automaticamente.

1. As leis de De Morgan são suas novas melhores amigas

Se eu tivesse que limitar esta lista a apenas um item, seria este. As leis de De Morgan são duas regras de transformação. Aplicadas ao Rego, elas permitem transformar uma regra sem alterar seu comportamento.

Elas costumam ser escritas assim:

¬(A ∧ B) ⇔ ¬A ∨ ¬B
¬(A ∨ B) ⇔ ¬A ∧ ¬B

Ou, em código:

!(a && b) <=> !a || !b
!(a || b) <=> !a && !b

Mas talvez um exemplo deixe tudo mais claro. As duas afirmações a seguir são equivalentes e exemplificam a aplicação da primeira lei:

  • Esta não é uma pizza com presunto e cogumelos.

  • Esta pizza não tem presunto ou não tem cogumelos.

Elas permitem transformar cláusulas AND em cláusulas OR e vice-versa, sem alterar o comportamento do código. Peraí — você pode perguntar — se essas transformações não mudam o comportamento do meu código, para que elas servem?

A ideia principal é que o Rego, como linguagem de consulta, é muito voltado para disjunções (instruções OR). Por exemplo, verificar se alguém do grupo tem qualificação para cortar uma pizza pode ser escrito assim:

default allow = false

allow {
  input.people[_].profession == "mathematician"
}

Mas como verificamos se todas as pessoas do grupo têm qualificação para cortar pizzas? Podemos, hum, primeiro criar um grupo com as pessoas qualificadas? E depois verificar o tamanho?

qualified_pizza_cutters[name] = person {
  person = input.people[name]
  person.profession == "mathematician"
}

default allow = false

allow {
  count(qualified_pizza_cutters) == count(input.people)
}

Isso não é muito bom. Além de ser difícil de ler, uma instrução assim é difícil de otimizar para um planejador de consultas, já que precisamos contar esses conjuntos — mesmo que pudéssemos encerrar a consulta assim que encontrássemos uma pessoa menor de idade no grupo.

É aí que entram as leis de De Morgan: elas nos dizem que podemos sempre escrever consultas como uma disjunção, negando algumas partes. Basta introduzir uma instrução auxiliar para negar a instrução original:

cannot_cut_pizza {
  input.people[_].profession != "mathematician"
}

E então é fácil escrever a política:

default allow = false

allow {
  not cannot_cut_pizza
}

Pronto! Eficiente e fácil de ler.

Conclusão: se uma política Rego parecer difícil de escrever, considere sempre se a política negada seria mais fácil de escrever e, depois, negue-a novamente! Viva a lógica!

2. or ou any?

Como acabamos de ver, é mais fácil ler e escrever Rego quando as instruções OR ficam no início e as instruções AND aparecem organizadas, uma após a outra, dentro dos corpos das regras. Mas, como programadores, geralmente não demora muito até nos depararmos com um caso em que a abordagem idiomática não funciona bem.

No Rego, isso costuma acontecer quando o corpo de uma regra já tem um bom número de consultas, por exemplo:

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

E se também quisermos permitir fatias de pizza? Podemos duplicar o corpo inteiro:

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
} {
  input.shape == 'circular_sector'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Isso não é muito bom. Na maioria dos casos, podemos introduzir uma regra auxiliar:

has_a_pizza_shape {
  input.shape == 'circle'
} {
  input.shape == 'circular_sector'
}

probably_a_pizza {
  has_a_pizza_shape
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Isso funciona bem neste caso. Às vezes, porém, acabamos com tantas regras auxiliares que fica difícil até pensar em bons nomes para elas! Nesses casos raros, podemos simplesmente fingir que existe no Rego uma instrução OR chamada any:

probably_a_pizza {
  any([input.shape == 'circle', input.shape == 'circular_sector'])
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Essa é uma forma perfeitamente legível de escrever isso. Quando usado com moderação, any pode facilitar muito a sua vida.

3. Compreensões any e all

Seguindo nessa mesma linha, any e all são extremamente úteis em vários outros contextos, especialmente com compreensões de lista — esses dois recursos juntos são muito mais poderosos do que a soma de suas partes.

Voltando ao primeiro exemplo, veja como podemos usar all junto com uma compreensão de lista para expressar a mesma política:

allow {
  all([person.profession == "mathematician" | person = input.people[_]])
}

Acho que essa versão é tão legível quanto a que criamos seguindo as leis de De Morgan. Geralmente, o contexto determina qual delas é mais idiomática.

  1. É mais fácil raciocinar sobre a política negada? Então, usamos a transformação de De Morgan.

  2. Ou é mais fácil pensar nos itens que vamos verificar como uma lista ou coleção? Nesse caso, uma compreensão de lista é adequada.

4. Use walk para percorrer árvores de documentos inteiras

As consultas Rego sempre terminam — o que é ótimo, mas tem um custo: em geral, não é possível escrever regras recursivas.

Por que regras recursivas são necessárias? Elas são muito úteis para lidar com documentos de entrada recursivos, como árvores profundamente aninhadas. Felizmente, o OPA oferece algumas funções integradas para ajudar.

Por exemplo, podemos estar escrevendo uma política para monitorar tópicos de discussão:

[
  {
    "author": "Alice",
    "message": "I think New York is at least as good as Italy for pizza",
    "replies": [
      {
        "author": "Bob",
        "message": "It's because of the water!",
        "replies": [
          {
            "Author": "Alice",
            "message": "I've heard that before but I'm not convinced.",
            "replies": []
          }
        ]
      },
    ]
  },
  {
    "author": "Charlie",
    "message": "You are a horrible person.",
    "replies": []
  }
]

Como obtemos as mensagens no Rego? Podemos tentar algo assim:

messages[message] {
  message = input[_].message
} {
  message = input[_].replies[_].message
}
  message = input[_].replies[_].replies[_].message
}

Isso funciona bem para níveis finitos de aninhamento, mas nem sempre podemos presumir que esse será o caso. Além disso, não é muito fácil de ler. Em vez disso, você pode usar walk para percorrer recursivamente a árvore e obter todos os campos message:

messages[message] {
  [_, value] := walk(walk_input)
  message = value.message
}

Depois que tivermos essas mensagens em uma regra Rego comum, podemos escrever uma política idiomática para bloquear tópicos de discussão que contenham palavrões.

deny {
  swear_words := {"hawaii", "pineapple"}
  contains(lower(messages[_]), swear_words[_])
}

Um exemplo do mundo real em que isso aparece são os módulos filhos do Terraform: aqui usamos walk no Regula para achatar os módulos filhos, de uma forma muito parecida com o que faríamos com as mensagens do tópico de discussão.

5. Carregamento dinâmico de pacotes e regras

Um dos aspectos interessantes do design do Rego é que todo o “universo” de regras e dados fica aninhado no mesmo documento. Seja para acessar a entrada do usuário, dados de arquivos JSON ou YAML ou regras dos seus pacotes, tudo são apenas referências:

input.people[0].order
data.recipes.pizza.pepperoni
data.policies.strict.allow

Como podemos percorrer referências no Rego usando variáveis ou curingas, podemos examinar dinamicamente o conjunto de pacotes carregados. Isso permite criar uma arquitetura em que basta adicionar novos arquivos de política e reunir todos eles em um relatório.

Colocar as políticas em pacotes separados também traz o benefício de permitir a depuração independente e eliminar qualquer possibilidade de conflito entre nomes de regras. Vamos colocar todas elas no namespace data.policies. A primeira usa as leis de De Morgan para verificar se há queijo na pizza:

package policies.cheese

contains_cheese {
  input.toppings[_] == "cheese"
}

deny["must contain cheese"] {
  not contains_cheese
}

A segunda política verifica se duas pessoas não vão brigar pela última fatia:

package policies.slices

deny["must be able to share with two people"] {
  input.slices % 2 != 0
}

Depois que definimos um namespace para nossas políticas, é relativamente simples reuni-las em um resumo: pegamos todos os casos de deny em data.policies e geramos um relatório legível para as pessoas.

package summary

report = msg {
  denies := {m | m := data.policies[_].deny[_]}
  msg := sprintf("%d policies failed\n%s", [
    count(denies),
    concat("\n", [sprintf("- %s", [m]) | denies[m]]),
  ])
}

Com a ajuda de jq para extrair os valores propriamente ditos, podemos gerar um relatório bem apresentável direto no terminal. Aqui, vamos usar -I para ler a entrada de stdin.

$ opa eval -I 'data.summary.report' -d . --format json | \
    jq -r '.result | .[] | .expressions | .[] | .value'
{"toppings": ["crab", "tomato sauce"], "slices": 6}
1 policies failed
- must contain cheese

Há muitos outros truques avançados que podem ser feitos acessando pacotes como dados — por exemplo, transformar input em um formato diferente dependendo de o pacote declarar input_version = 1 ou input_version = 2. Tudo isso no Rego!