In this article
Erste Schritte mit Practical Rego
Auch wenn sich viele der Informationen in diesem Beitrag mit der offiziellen Rego-Referenz überschneiden, legt dieser Leitfaden für den Einstieg den Schwerpunkt auf Programmierparadigmen (Logik versus prozedurales Vorgehen). Das ist hilfreich für Programmierer, die imperative Sprachen wie Python oder Java gewohnt sind.
Normale Links (z. B. Wikipedia) führen zu weiterführenden Informationen, während Links in eckigen Klammern (z. B. [1) Quellen für Aussagen in diesem Beitrag sind.
Alle Codeausschnitte in diesem Beitrag wurden mit OPA v0.54.0 ausgeführt.
Vielen Dank an Jasper Van der Jeugt, der einen Entwurf dieses Blogbeitrags gelesen und Verbesserungsvorschläge gemacht hat.
Einführung in Rego
Das Projekt Open Policy Agent („OPA“) hat seit seiner Aufnahme als Graduierungsprojekt der CNCF [0] viel Aufmerksamkeit erhalten. OPA ist eine universell einsetzbare Policy-Engine zur Durchsetzung von Autorisierungs-Frameworks. Die Policies werden in einer Abfragesprache namens Rego definiert, um die es in diesem Beitrag geht.
Rego basiert auf Datalog, einer deklarativen logischen Abfragesprache. Sie bietet Konstrukte für Mustererkennung, Filterung und Iteration. Rego ist mit SQL vergleichbar, da beide universell einsetzbare Abfragesprachen sind. SQL wurde jedoch für die Arbeit mit tabellarischen Daten entwickelt, während Rego mit JSON-formatierten Daten arbeitet. Als deklarative logische Abfragesprache verfügt Rego über einige Eigenschaften, die für andere Programmiersprachen untypisch sind. Diese Eigenschaften sind für das Verständnis von Rego entscheidend.
Deklarative Programmierung
Deklarative Programmierung drückt die Logik und Regeln eines Problems aus, ohne die Schritte zu seiner Lösung explizit zu beschreiben. Im Mittelpunkt steht das Was statt des Wie. Ein verbreiteteres Programmierparadigma ist dagegen das „imperative“ Programmieren, bei dem Anweisungen den Zustand eines Programms ändern, um ein Ergebnis zu erzeugen. SQL ([1], S. 79) und HTML [2] sind gängige Beispiele für deklarative Sprachen. Infrastructure-as-Code-Sprachen wie HCL verfolgen in der Regel ebenfalls einen deklarativen Ansatz, der jedoch oft mit imperativen Elementen kombiniert wird [3]. Viele Programmiersprachen ermöglichen eine Mischung beider Paradigmen, tendieren aber meist stärker zur imperativen Programmierung – etwa Python [4].
Beispiel für imperative Programmierung (Python):
def is_even(x):
remainder = x % 2
if remainder == 0:
return True
return FalseBeispiel für deklarative logische Programmierung (Rego):
is_even {
remainder == 0
remainder = input % 2
}
In Rego spielt die Reihenfolge der Anweisungen innerhalb einer Regel keine Rolle.
Logische Programmierung
Datalog verwendet das Paradigma der logischen Programmierung, eine spezielle Form der deklarativen Programmierung, die auf formaler Logik basiert [5], [6]. Logische Programme bestehen aus sogenannten Fakten und Regeln, die Beziehungen und Einschränkungen festlegen, um den Problembereich zu beschreiben. In Rego werden diese Fakten und Regeln mithilfe der Horn-Klausel ausgedrückt, einer logischen Formel, die auch in Datalog verwendet wird [6].
Eine Inferenz-Engine leitet dann die Lösung des Problems ab, indem sie die Ausführungsreihenfolge bestimmt. Anders ausgedrückt: Die Engine übersetzt den Code in mehrere logische Formeln und löst diese anschließend. Diese Architektur wirkt Nebeneffekten stark entgegen, ist robust und eignet sich besser für die formale Verifikation.
Bei Rego übernimmt OPA unter anderem die Funktion der Inferenz-Engine. Um die Terminierung von Rego besser zu gewährleisten, wurden neben weiteren Maßnahmen Schritte unternommen, um bestimmte Formen der Rekursion zu verhindern [7], [8]. Darüber hinaus bietet Rego aufgrund der zuvor beschriebenen Designentscheidungen Entscheidbarkeit als weitere wichtige rechnerische Eigenschaft [9]. Entscheidbarkeit stellt sicher, dass die Entscheidungsfindung bei der Policy-Auswertung immer ein Ergebnis liefert. Rego-Policies bleiben also nicht hängen und optimieren die Zeitkomplexität. Diese Robustheit von Rego ist vermutlich einer der Hauptgründe, die Sprache in Verbindung mit OPA einzusetzen. Rego kann Teil eines kritischen Pfads einer Geschäftsanwendung sein und seine Aufgaben zuverlässig erfüllen.
Wie bereits erwähnt, besteht Rego aus Fakten und Regeln. Jede Codeanweisung stellt eine logische Regel dar. Wenn sich eine logische Regel nicht auflösen lässt, ist keine weitere Codeauswertung erforderlich. Folglich muss jede Codezeile „truthy“ sein. Rego übernimmt dieses Verhalten, da es auf Datalog basiert; es ist also nicht spezifisch für Rego. Für Verfasser von Rego-Policies ist es dennoch relevant. Warum es wichtig ist, dieses Verhalten zu kennen, erfahren Sie im Abschnitt „Fallstricke“.
Existenzquantifizierung
Logische Quantoren in der Programmierung legen den Geltungsbereich einer Aussage über eine Sammlung von Elementen fest.
Rego verwendet die Existenz quantifizierung (denken Sie an FOR ANY). Dabei wird geprüft, ob eine Bedingung für mindestens ein Element in einer Sammlung zutrifft.
Imperative Programmiersprachen verwenden Quantifizierung nicht von sich aus wie in der logischen Programmierung. Sie bieten jedoch Konstrukte, mit denen sich prüfen lässt, ob eine Bedingung für alle Elemente einer Sammlung zutrifft. Das entspricht der Allquantifizierung (denken Sie an FOR ALL).
In beiden Fällen durchläuft eine Schleife eine Sammlung und verhält sich dabei anders, je nachdem, ob sie nach Elementen sucht, die die Bedingungen erfüllen (Existenzquantifizierung), oder prüfen soll, ob die Bedingungen für alle Elemente gelten (Allquantifizierung).
Die Idee der Existenzquantifizierung wird umgesetzt, indem „alle Variablenbelegungen“ ermittelt werden, die eine Abfragebedingung erfüllen [10].
Das mag abstrakt klingen, wird in den folgenden Abschnitten jedoch anhand praxisnaher Beispiele erläutert.
Die Existenzquantifizierung ist vielen Programmierern nicht vertraut und kann zu Logikfehlern führen, wie im Abschnitt „Fallstricke“ erläutert.
Einstiegspunkt
Rego verfügt nicht über eine „main“-Funktion, die die Ausführung startet. Stattdessen wird OPA mit einem Einstiegspunkt konfiguriert. Ein Einstiegspunkt ist eine Zeichenfolge, die auf Rego-Regeln verweist, zum Beispiel data.mypolicies.den. Mit diesem Einstiegspunkt wird OPA angewiesen, alle Rego-Regeln deny im Namespace mypolicies abzufragen, die Ergebnisse aller Regeln zusammenzuführen und zurückzugeben. Es können mehrere Einstiegspunkte angegeben werden. Sind die Regelbedingungen nicht erfüllt, gilt die Regel als „nicht auflösbar“ und wird nicht in das Endergebnis aufgenommen.
Es reicht nicht aus, OPA nur einen Einstiegspunkt zur Ausführung bereitzustellen. OPA benötigt ein input-Dokument, das in Rego als globale Variable dient und alle Daten aus externen Quellen enthält.
Optimierung
Es mag verfrüht erscheinen, an dieser Stelle Rego-Optimierungen zu erwähnen. Eine davon ist jedoch besonders erwähnenswert, da sie zu unerwartetem Verhalten führen kann.
Die Optimierung heißt „Vorzeitiger Abbruch bei der Regelauswertung“ und kann möglicherweise die Ursache eines Logikfehlers sein. Die offizielle Dokumentation beschreibt diese Maßnahme ausführlich und anschaulich. Es handelt sich jedoch um ein fortgeschrittenes Thema, das beim Einstieg in Rego leicht übersehen wird. Im Abschnitt „Fallstricke“ wird dieses Risiko behandelt und es werden Workarounds vorgeschlagen.
Der kritische Teil der Optimierung wird folgendermaßen beschrieben:
Wenn für eine Regel oder eine Gruppe von Regeln ein „vorzeitiger Abbruch“ möglich ist, werden Iterationen innerhalb dieser Regel abgebrochen, sobald eine Variablenbindung den Regelrumpf erfüllt:
package earlyexit.iteration
p {
some p
data.projects[p] == "project-a"
}
Da sich das Ergebnis von data.earlyexit.iteration.p nicht mehr ändern kann, sobald eine Variablenbindung die Bedingungen erfüllt, finden keine weiteren Iterationen statt. – OPA-Dokumentation
Anders ausgedrückt: OPA beendet die Schleife über data.projects, sobald es ein Element findet, das der Zeichenfolge project-a entspricht. Das ist eine direkte Folge der Existenzquantifizierung. Das ist sinnvoll und korrekt – wichtig ist jedoch, sich dieses Verhaltens bewusst zu sein.
Gleichheit
Rego kennt drei Arten von Gleichheitsoperatoren, die jeweils eine andere Bedeutung haben:
Gleichheitsoperator | Bedeutung |
|---|---|
| Der Vergleichsoperator, mit dem Variablenwerte verglichen werden |
| Der Zuweisungsoperator, mit dem Variablen Werte zugewiesen werden |
| Der Unifikationsoperator, der Zuweisung und Vergleich kombiniert |
Der Operator = führt zuerst die Zuweisung und anschließend den Vergleich durch. Scheitert die Zuweisung, vergleicht OPA trotzdem die Werte:
# input
[1, 2, 5]
# code
default allow := false
allow {
input[_] = 5 # assignment fails, comparison evaluates to `true`
# (`input[_] := 5` creates error "cannot assign to ref")
}
# output
{
"allow": true
}
Sofern es keinen Grund für die Verwendung des Operators `=` gibt, sollten Sie ihn vermeiden [13]. Verwenden Sie stattdessen den Operator `:=` für Zuweisungen und den Operator `==` für Vergleiche.
Policies, Regeln und Funktionen
Bevor Sie zu diesem Abschnitt springen, sollten Sie die Rego-Datenstrukturen kennen: Skalarwerte, Zeichenfolgen, zusammengesetzte Werte und Comprehensions.
Policies
Eine Policy ist ein übergeordnetes Konzept in Rego und bezeichnet eine Gruppe von Regeln, die das Verhalten und die Einschränkungen eines Systems festlegen. Das folgende Dokument ist beispielsweise eine Policy mit den Rego-Regeln allow und deny:
package mypolicy
allow {
input.name == "bar"
}
deny {
input.name == "foo"
}
Regeln
Regeln erzeugen sogenannte „virtuelle Dokumente“ – Datenstrukturen, die zur Laufzeit berechnet werden. Weitere Informationen zu Regeln finden Sie hier. Zum Beispiel:
# input
[
"world-a",
"universe-a",
"world-b",
"universe-b"
]
# code
worlds[world] {
item := input[_] # loop over `input`
startswith(item, "world")
world := item # var `world` is return value
}
universes[universe] {
item := input[_]
startswith(item, "universe")
universe := item
}
# output
{
"universes": [
"universe-a",
"universe-b"
],
"worlds": [
"world-a",
"world-b"
]
}
Wenn kein Rückgabewert definiert ist, geben Rego-Regeln laut Dokumentation true zurück: > Wird der Wert weggelassen, ist der Standardwert true. – Dokumentation
Die folgenden beiden Regeln verhalten sich daher gleich:
deny := true if { # rule header
true # rule body
}
deny { # optimized rule header
true
}Wie bereits erwähnt, wird eine Regel nur dann vollständig ausgeführt, wenn alle Bedingungen im Regelrumpf erfüllt sind. Die folgende Regel gibt daher nichts zurück:
allow := true {
false
}Regeln, die Daten vom Typ set oder object zurückgeben, werden inkrementelle Regeln genannt und verwenden eine leicht abweichende Syntax:
# input
[
"a",
"b",
"c",
"c"
]
# code
return_set[item] {
item := input[_]
}
return_object[key] := value {
value := input[key]
}
# output
{
"return_object": {
"0": "a",
"1": "b",
"2": "c",
"3": "c"
},
"return_set": [
"a",
"b",
"c"
]
}
Rego-Regeln unterstützen keine Parameter. Zum Beispiel ist return_set(param1 ungültiger Regelcode), stattdessen können jedoch [Funktionen][#functions] verwendet werden.
Existenzquantifizierung
Das Verhalten von OPA bei der Regelausführung ist festgelegt: Bei der Auswertung von Regelrümpfen sucht OPA nach Variablenbindungen, durch die alle Ausdrücke wahr werden. – Dokumentation
Wie funktioniert die Allquantifizierung in imperativen Sprachen? Ein Beispiel mit Python:
for n in numbers: # think "FOR ALL" items `n` in numbers
if n % 2 == 0:
print(n)Dieser Codeausschnitt findet gerade Zahlen im Array numbers. Dieselbe Aufgabe lässt sich auch in Rego lösen. Zum Beispiel:
even_numbers[n] {
n := input[_]
n % 2 == 0 # think "FOR ANY" item `n` in inputs
}Im obigen Beispiel wird das Schlüsselwort some verwendet, um die Variable n zu deklarieren. OPA versucht, eine beliebige Zahl n im Array input zu finden, die die Bedingung erfüllt, gerade zu sein. Ist die Bedingung erfüllt, wird die Variablenbindung von n zurückgegeben.
OPA ermittelt anhand aller passenden Variablenbindungen, ob die Existenzquantifizierung erfüllt ist.
Es ist nicht zwingend erforderlich, Variablen mithilfe von some explizit zu deklarieren. Dies kann jedoch die Klarheit und Verständlichkeit verbessern.
Ein weiteres Beispiel, um alle Werte in einem Objekt zu finden, die mit der Zeichenfolge 1 beginnen:
# input
{
"a": "1a",
"b": "2b",
"c": "1c"
}
# code
prefixed[item] {
item := input[_]
startswith(item, "1")
}
# output
{
"prefixed": [
"1a",
"1c"
]
}Die Zeile item := input[_] durchläuft das Eingabedokument. Das zweite Eingabeelement b erfüllt die Regelbedingung startswith(l, "1") nicht. OPA bricht die Schleife nicht ab, sondern ignoriert das Ergebnis der Iteration und fährt mit dem nächsten Element c fort, um weiterhin „alle Variablenbindungen zu suchen“.
Dieses Verhalten ist nützlich, birgt jedoch das Risiko eines Logikfehlers.
Funktionen
Funktionen in Rego ähneln Regeln, verhalten sich jedoch anders:
greet(name) := msg {
msg := sprintf("Hello %s!", [name])
}
greeting := greet("Bob") # returns "Hello Bob!"Eine Funktion ohne Parameter wird nicht unterstützt, da eine solche Definition als Regel interpretiert wird:
greet() := msg {
msg := "Hello"
}
greet() # creates error "rego_parse_error:
# rule argument list must take at least one argument"Da Regeln virtuelle Dokumente darstellen und Funktionen nicht, müssen sie unterschiedlich aufgerufen werden:
# input
[
[1, 1],
[2, 2],
[3, 3]
]
# code
add_function(inp) := result { # define function
result := [ sum | # create array comprehension
arr := inp[_]
sum := arr[0] + arr[1]
]
}
output := add_function(input) # store result of function
dummy_rule_iterate_function_output[item] {
item := output[index] # work with function result
}
add_rule = result { # define rule
result := [ sum | # create array comprehension
arr := input[_]
sum := arr[0] + arr[1]
]
}
dummy_rule_iterate_rule[item] {
item := add_rule[index] # work with rule result
}
# output
"add_rule": [
2,
4,
6
],
"output": [
2,
4,
6
]
Sowohl Regeln als auch Funktionen können verwendet werden, um Funktionalität zwischen Policies gemeinsam zu nutzen.
Kontrollfluss
Grundlagen
Logik, die mehrere Bedingungen prüft, wird erstellt, indem alle Bedingungen zeilenweise aufgeführt werden:
# input
{
"name": "foo"
}
# code
my_rule {
startswith(input.name, "f") # first condition (AND..)
endswith(input.name, "o") # second condition
}
# output
{
"my_rule": true
}
Um einen Kontrollfluss mit verschiedenen Szenarien zu erstellen, in denen eine Regel zu true ausgewertet werden soll, definieren Sie die Regel mehrfach (der Regelkopf muss bei jeder Definition identisch sein). Wird eine der Regeln zu true ausgewertet, lautet der Rückgabewert true. Geben die Regeln Sammlungen zurück, werden die Elemente aller Sammlungen zusammengeführt, deren Regelbedingungen erfüllt sind.
Im folgenden Beispiel geben die Regeln my_rule einen booleschen Wert zurück:
# input
{
"name": "foo"
}
# code
my_rule {
count(input.name) == 3
}
my_rule {
input.name == "foobar" # not true; aborts execution
}
# output
{
"my_rule": true
}Die if/else-Konstruktion lässt sich ähnlich wie in imperativen Programmiersprachen mit dem Schlüsselwort else implementieren:
# input
{
"name": "foo"
}
# code
my_rule := msg {
input.name == "foo"
msg := "Hello foo!"
} else := msg {
msg := "Hello anonymous"
}
# output
{
"my_rule": "Hello foo!"
}Die Negationslogik wird mit dem Schlüsselwort not implementiert:
# input
{
"a": "1a",
"b": "2b",
"c": "1c"
}
# code
prefixed[item] {
item := input[_]
not startswith(item, "1")
}
# output
{
"prefixed": [
"2b"
]
}Der Operator not wird für die logische Negation verwendet, während der Operator != Werte vergleicht, um Ungleichheit festzustellen.
Schleifen
Schleifen werden in Rego als Iterationen bezeichnet und sind gut dokumentiert. Allerdings sind Schleifen in der Regel nicht erforderlich, da stattdessen die existentielle Quantifizierung verwendet wird.
Mehrdeutige Syntax
Rego verwendet dieselbe Syntax, um über Werte in Sets, Objekten und Arrays zu iterieren und auf sie zuzugreifen. Daher ist es wichtig, die verwendeten Datenstrukturen zu kennen. Je nach Datenstruktur ändert sich das Kontrollflussverhalten, obwohl dieselbe Syntax verwendet wird:
# input
{
"array": [0, 3, 5],
"object": {"foo": "bar"}
}
# code
iterate_array[msg] { # returns array of strings
value := input.array[index] # loop over array
msg := sprintf("%d: %d", [index, value])
}
access_key_in_object := v { # returns string
v := input.object["foo"] # get value by key "foo"
}
create_set := s {
s := { v |
v := input.array[_]
}
}
iterate_set[v] { # returns array of integers
create_set[v] # iterate over set
}
check_key_in_object { # returns `true`
input.object["foo"] # check if key "foo" exists
}Ohne Kontext lässt sich die Semantik von Rego-Code nicht immer aus einer Anweisung ableiten.
Beispiel 1:
var3 := var1[var2]
Wenn die Datenstruktur von var1 ein … ist
| wird über |
| muss |
Beispiel 2:
var1[var2]
Wenn die Datenstruktur von var1 ein … ist
| wird über |
| wird geprüft, ob der Schlüssel |
| wird über |
Fallstricke
Fehler schleichen sich in jede Codebasis ein. Automatisierte Erkennung und Prävention sind wirksame Maßnahmen, die Teil jedes Rego-Entwicklungsprozesses sein sollten.
Zwei nützliche Tools dafür:
OPA enthält den integrierten Befehl
check, der beim Erkennen von Compilerfehlern und problematischen Code-Mustern hilft.
Styra, das Unternehmen hinter OPA, hat
regalveröffentlicht, einen Rego-Linter, der Quellcode auf eine Vielzahl von Problemen untersucht – von Code-Stil bis hin zu Bugs.
Optimierung durch vorzeitigen Abbruch
Wie im Abschnitt „Optimierung“ erläutert, bricht Rego die Ausführung vorzeitig ab, wenn das Endergebnis bereits feststeht, bevor der gesamte Code ausgeführt wurde.
Stellen Sie sich vor, jemand möchte eine Policy erstellen, die den Zugriff nur dann erlaubt, wenn alle Verbindungen des Benutzers zum Unternehmen von einer seiner Niederlassungen ausgehen:
# input
[
"on-prem",
"remote",
"on-prem",
"on-prem"
]
# code
default allow := false
allow {
input[_] == "on-prem"
}
# output
{
"allow": true
}Diese Policy ergibt true, obwohl eine Verbindung von einem entfernten Standort ausgeht. Dieses Verhalten ist für Programmierer, die mit logischer Programmierung nicht vertraut sind, möglicherweise schwer zu erkennen.
In Python könnte der Code beispielsweise wie im folgenden Beispiel aussehen und wie beabsichtigt funktionieren:
origins = ['on-prem', 'remote', 'on-prem', 'on-prem']
def allow():
for origin in origins:
if origin != 'on-prem':
return 1
return 0
if __name__ == '__main__':
raise SystemExit(allow())Der Logikfehler liegt an Regos Optimierung durch vorzeitigen Abbruch: Sobald Rego die Bedingungen der Regel allow als true auswerten kann, wird die Ausführung beendet. Das ist der Fall, weil die existentielle Quantifizierung erfüllt ist, sobald im ersten Durchlauf on-prem an origin gebunden wird. Das unterscheidet sich deutlich von imperativer Programmierung, bei der die Bedingung für jedes Element von origins ausgewertet wird, bevor eine Entscheidung getroffen wird.
Um den Fehler zu beheben, muss die universelle Quantifizierung (FOR ALL) angewendet werden.
Eine Möglichkeit zur Behebung ist die Verwendung einer comprehension:
default allow := false
allow {
all_allows := [ allowed |
origin := input[_]
origin == "on-prem"
allowed := true
]
count(all_allows) == count(input)
}Eine weitere Möglichkeit ist die Verwendung des Schlüsselworts every (eingeführt in OPA v0.38.0) [14] [15]:
import future.keywords.every
default allow := false
allow {
every origin in input {
origin == "on-prem"
}
}Eine dritte Möglichkeit besteht darin, die Regellogik mithilfe einer Negation umzukehren:
# input
[
"remote",
"on-prem",
"on-prem"
]
# code
default deny := false
deny {
input[_] != "on-prem"
}
# output
{
"deny": true
}Diese deny-Regel gibt wie erwartet true zurück, wenn eine der Verbindungen des Benutzers von außerhalb eines Unternehmensstandorts ausgeht.
Das Schlüsselwort every ist jedoch die geeignetste Option, da es die universelle Quantifizierung ausdrücklich erzwingt und die Absicht des Policy-Autors daher klarer und bewusster zum Ausdruck bringt [14].
Bei jeder Regel, die eine Iteration enthält, sollte der Policy-Autor überlegen, ob eine universelle oder existentielle Quantifizierung das gewünschte Verhalten hervorruft.
Nicht definierte Werte
Im obigen Abschnitt „Einführung“ wird erklärt, dass die Codeausführung beendet wird, wenn eine Codezeile nicht zu true ausgewertet wird. Konkret gilt: Lässt sich eine Logikregel nicht auflösen, weil ihre Bedingung nicht erfüllt ist, gibt die Regel nicht false zurück, wie Programmierer imperativer Programmiersprachen vielleicht erwarten würden. Stattdessen wird die Codeausführung einfach beendet. Das gilt auch für undefined-Werte oder -Attribute [17].
Rego-Code wird beispielsweise zu undefined ausgewertet, wenn auf einen nicht vorhandenen Namespace oder ein nicht vorhandenes Objektattribut zugegriffen wird.
Das kann zu unbeabsichtigtem Verhalten führen und ist möglicherweise schwer zu debuggen:
# input
{
"message": "world"
}
# code
deny {
input.mesage == "world"
}
# output
{}Das Attribut input.mesage enthält einen Tippfehler und ist nicht vorhanden. Rego kann erst zur Laufzeit feststellen, ob dieses Attribut existiert oder undefined ist. Daher beendet OPA die Auswertung dieser Zeile einfach ohne Meldung.
Zur Fehlersuche ist es in der Regel hilfreich, mehrere print()-Anweisungen in der Codebasis zu verteilen, um die fehlerhafte Codezeile einzugrenzen. Denken Sie auch daran, die Package-Namespaces zu überprüfen.
Eine weitere Möglichkeit, die fehlerhafte Zeile zu finden, besteht darin, große Teile der Codebasis zu deaktivieren (auszukommentieren) und sie schrittweise wieder zu aktivieren, bis das Problem sichtbar wird.
JSON-„true“
Das input-Dokument für OPA besteht aus JSON-formatierten Daten, die sowohl boolesche Werte als auch Zeichenfolgen unterstützen [18]. In der Praxis werden boolesche Werte manchmal als Zeichenfolgen dargestellt (z. B. „true“), was zu unbeabsichtigten Policy-Entscheidungen führen kann. Zum Beispiel:
# input
{
"privileged": "true"
}
# code
deny {
input.privileged == true
}
# output
{}Eine mögliche Abhilfe wäre eine Hilfsfunktion, die Zeichenfolgen berücksichtigt:
1# input
2{
3 "privileged": "true"
4}
5
6# code
7is_true(b) := ret {
8 is_boolean(b)
9 b
10 ret := b
11} else := ret {
12 b == "true"
13 ret := true
14} else = false {
15 true
16}
17
18deny {
19 is_true(input.privileged)
20}
21
22# output
23{
24 "deny": true
25}Tests
Tests sind entscheidend, um das erwartete Verhalten von Policies sicherzustellen. OPA bietet einen test-Befehl, mit dem sich Tests bequem ausführen lassen.
Mit der folgenden Policy (Datei policy.rego):
package mypolicy
deny {
input.privileged == true
}lassen sich ganz einfach Tests erstellen (Datei policy_tests.rego):
package mypolicy
test_deny_privileged {
deny with input as {"privileged": true}
}
test_deny_unprivileged {
not deny with input as {"privileged": false}
}Der Befehl ./opa test policy.rego policy_tests.rego führt die Tests aus und zeigt das Ergebnis an: PASS: 2/2.
Debugging
Debugging ist bei jedem Programmierprojekt wichtig. OPA bietet eine REPL und einen eval-Befehl, die sich für das Debugging instrumentieren lassen. Einen typischen Debugger wie gdb, mit dem sich der Code schrittweise ausführen und Symbole untersuchen lassen, gibt es jedoch noch nicht. Daher ist die Ausgabe von Variablenwerten nach wie vor eine wichtige Debugging-Methode.
In diesem Beispiel werden die Tests aus dem vorherigen Kapitel „Tests“ um einen Testfall erweitert, der die Policy mit der Eingabe „true“ ausführt:
test_deny_string {
deny with input as {"privileged": "true"}
}Wie erwartet schlägt der Testfall fehl:
$ ./opa test policy.rego policy_test.rego
policy_test.rego:
data.mypolicy.test_deny_string: FAIL (85.375µs) --------------------------------------------------------------------------------
PASS: 2/3
FAIL: 1/3
Zur Untersuchung des Problems lässt sich der Wert von input.privileged mit print() ausgeben:
deny {
print(sprintf("value of `privileged`: %v", [input]))
input.privileged == true
}Bei der erneuten Ausführung des Testfalls wird der Wert angezeigt:
$ ./opa test policy.rego policy_test.rego
policy_test.rego:
data.mypolicy.test_deny_string: FAIL (88.875µs)
value of `privileged`: {"privileged": "true"} --------------------------------------------------------------------------------
PASS: 2/3
FAIL: 1/3
Die Ausgabe zeigt nun die Datenstruktur von input.privileged: Es handelt sich um eine Zeichenfolge statt eines booleschen Werts. Damit lässt sich eine Korrektur erstellen.
OPA stellt die print()-Funktion seit OPA-Version v0.34.0 bereit. Glücklicherweise bricht print() bei undefined-Werten nicht ab, sondern zeigt stattdessen <undefined> an.
Ein nützliches Drittanbieter-Tool zum einfachen Debuggen von Code, Testen von Policies und mehr ist fregot.
Häufige Fehler
Complete rules must not produce multiple outputs
Dieser Fehler tritt auf, wenn eine oder mehrere Regeln derselben Definition dieselbe Eingabe erhalten, aber unterschiedliche Ausgaben erzeugen, zum Beispiel:
# input
-
# code
a_rule := res {
res := "b"
}
a_rule := res {
res := "c"
}
# outputpolicy.rego:7: eval_conflict_error: complete rules must not produce multiple outputs
Die erste Definition von a_rule gibt „b“ zurück, während die zweite Definition „c“ zurückgibt. Das führt zu nicht-deterministischem Verhalten.
Oft sind alle Rückgabewerte gültig und als Ausgabe wird eine Vereinigung erwartet. Das lässt sich erreichen, indem stattdessen ein Set zurückgegeben wird:
# input
-
# code
a_rule[res] {
res := "b"
}
a_rule[res] {
res := "c"
}
# output
{
"a_rule": [
"b",
"c"
]
}Eine weitere gängige Möglichkeit besteht darin, beide Regeln zusammenzuführen und eine Sammlung zurückzugeben. Führt keine der beiden Optionen zur gewünschten Lösung, muss möglicherweise die Logik der Policy überarbeitet werden.
OPA für kritische Anwendungspfade
Rego und OPA sind zuverlässige Werkzeuge zum Erstellen und Auswerten von Policies. Die Garantien für Entscheidbarkeit und Terminierung schaffen Sicherheit und ermöglichen leistungsstarke Autorisierungs-Frameworks, die Teil jedes geschäftskritischen Anwendungspfads sein können.
Die Verwendung von Rego ist jedoch mit Kosten verbunden, da viele Programmierer mit bestimmten Konzepten der logischen Programmierung nicht vertraut sind, etwa mit der existentiellen Quantifizierung oder der im Abschnitt „Einführung“ erläuterten Optimierung von OPA durch vorzeitigen Abbruch.
Daher müssen sich Programmierer zunächst eine steile Lernkurve hinaufarbeiten, was eine Einstiegshürde darstellt. Angesichts der möglichen Fallstricke sollten außerdem die Kosten für die Einführung von OPA realistisch eingeschätzt und mit alternativen Ansätzen verglichen werden. Dazu gehört beispielsweise die Wahl einer imperativen Programmiersprache mit einem ausgereifteren Entwickler-Tool-Ökosystem und einer größeren Zahl verfügbarer Programmierer (natürlich sind auch imperative Sprachen nicht frei von Fallstricken).
Wenn beispielsweise bei der Softwareentwicklung das Risiko von Nebenwirkungen akzeptabel und die Markteinführungszeit entscheidend ist, sollte die Entscheidung für Rego unter Berücksichtigung dieser Folgen getroffen werden.
Priorisieren Sie, was am wichtigsten ist
Unternehmen benötigen einen ganzheitlichen Ansatz zur Risikopriorisierung. Nutzen Sie mit Snyk eine kontextbezogene, risikobasierte Priorisierung.