Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: OR operator #427

Merged
merged 4 commits into from
Sep 26, 2023
Merged

feat: OR operator #427

merged 4 commits into from
Sep 26, 2023

Conversation

guicassolato
Copy link
Collaborator

@guicassolato guicassolato commented Sep 20, 2023

Adds support for logical disjunction (aka "OR" operator) in 'when' conditions and pattern-matching authorization rules.

Closes #424.


Introduces a new field any: Array<expression> to the AuthConfig API to express logical disjunction, along with all: Array<expression> to enforce the AND operation (default if omitted). Where expression can be any of the following:

  • a pattern composed of selector, operator and value;
  • a reference to a named pattern (patternRef: String);
  • a nested any field;
  • a nested all field.

For backward compatibility, the AND operator is assumed for all lists of patterns not grouped under an explicit any field declaration. I.e., it is equivalent to grouping the patterns under a single all field declaration.

Examples

Example 1. To allow anonymous access (no authentication required) for all requests to path /test/* OR method GET (ocasionally both):

spec:
  authentication:
    anonymous-request:
      when:
      - any:
        - selector: context.request.http.path
          operator: matches
          value: ^/test/.*
        - selector: context.request.http.method
          operator: eq
          method: GET
      anonymous: {}

Example 2. In a more complex condition, with nested logical operators, to express host == 'foo.apis.io' AND ((path =~ '/test*' AND (method == 'POST' OR method == 'PUT')) OR method == 'GET'):

spec:
  authentication:
    anonymous-request:
      when:
      - selector: context.request.http.host
        operator: eq
        value: foo.apis.io
      - any:
        - all:
          - selector: context.request.http.path
            operator: matches
            value: ^/test/.*
          - any:
            - selector: context.request.http.method
              operator: eq
              value: POST
            - selector: context.request.http.method
              operator: eq
              value: PUT
        - selector: context.request.http.method
          operator: eq
          value: GET
      anonymous: {}

Verification steps

Deploy

make local-setup FF=1
kubectl port-forward deployment/envoy 8000:8000 2>&1 >/dev/null &

Try the new feature

① Implicit AND (i.e. the existing syntax, which continues to be supported):

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    deny-specific-endpoints:
      when:
      - selector: context.request.http.method
        operator: eq
        value: POST
      - selector: context.request.http.path
        operator: eq
        value: /foo
      opa:
        rego: "allow = false"
EOF
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X GET -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -i
# HTTP/1.1 200 OK

② Same as above with explicit AND operator (all):

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    deny-specific-endpoints:
      when:
      - all:
        - selector: context.request.http.method
          operator: eq
          value: POST
        - selector: context.request.http.path
          operator: eq
          value: /foo
      opa:
        rego: "allow = false"
EOF
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X GET -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -i
# HTTP/1.1 200 OK

③ Logical disjunction (any):

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    deny-specific-endpoints:
      when:
      - any:
        - selector: context.request.http.method
          operator: eq
          value: POST
        - selector: context.request.http.method
          operator: eq
          value: PUT
      opa:
        rego: "allow = false"
EOF
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X GET -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -X PUT -i
# HTTP/1.1 403 Forbidden

④ Nested OR and AND expression:

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    deny-specific-endpoints:
      when:
      - any:
        - all:
          - selector: context.request.http.method
            operator: eq
            value: POST
          - selector: context.request.http.path
            operator: eq
            value: /foo
        - all:
          - selector: context.request.http.method
            operator: eq
            value: PUT
          - selector: context.request.http.path
            operator: eq
            value: /foo
      opa:
        rego: "allow = false"
EOF
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X PUT -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X GET -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -X PUT -i
# HTTP/1.1 200 OK

⑤ Same as above with implicit AND:

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    deny-specific-endpoints:
      when:
      - selector: context.request.http.path
        operator: eq
        value: /foo
      - any:
        - selector: context.request.http.method
          operator: eq
          value: POST
        - selector: context.request.http.method
          operator: eq
          value: PUT
      opa:
        rego: "allow = false"
EOF
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X PUT -i
# HTTP/1.1 403 Forbidden
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X GET -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -X PUT -i
# HTTP/1.1 200 OK

⑥ Edge case: Invalid condition:

kubectl apply -f -<<EOF
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: talker-api-protection
spec:
  hosts:
  - talker-api-authorino.127.0.0.1.nip.io
  authorization:
    foo:
      when:
      - all:
        - invalid: object
      opa:
        rego: "allow = false"
EOF

Because CRDs defined containing recursive types (e.g. an object/struct that defines a field whose type is the same type as the object itself) result in invalid OAS, the new fields all and any have to be marked with x-kubernetes-preserve-unknown-fields. Therefore, Kube API won't validate the scheme of these field, effectively accepting any array. The AuthConfig controller should not fail nonetheless, but just ignore the invalid condition instead. In the future, this can be improved with further validation.

curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X POST -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/foo -X PUT -i
# HTTP/1.1 200 OK
curl http://talker-api-authorino.127.0.0.1.nip.io:8000/bar -X PUT -i
# HTTP/1.1 200 OK

…tching authorization)

Adds support for logical disjunction (aka "OR" operator) in 'when' conditions and pattern-matching authorization rules.

A new field `any: Array<expression>` was introduced to the AuthConfig API to express logical disjunction, along with `all: Array<expression>` to enforce the default AND operation, where `expression` can be any of the following:
- a pattern composed of `selector`, `operator` and `value`;
- a reference to a named pattern (`patternRef: String`);
- a nested `any` field;
- a nested `all` field.

For backward compatibility, the AND operator is assumed for all lists of patterns not grouped under an explicit `any` field declaration. I.e., it is equivalent to grouping the patterns under a single `all` field declaration.

Example 1) To allow anonymous access (no authentication required) for all request to path `/test/*` OR method `GET` (ocasionally both):

```yaml
spec:
  authentication:
    anonymous-request:
      when:
      - any:
        - selector: context.request.http.path
          operator: matches
          value: ^/test/.*
        - selector: context.request.http.method
          operator: eq
          method: GET
      anonymous: {}
```

In a more complex condition, with nested logical operators, to express `host == 'foo.apis.io' AND ((path =~ '/test*' AND (method == 'POST' OR method == 'PUT')) OR method == 'GET')`:

```yaml
spec:
  authentication:
    anonymous-request:
      when:
      - selector: context.request.http.host
        operator: eq
        value: foo.apis.io
      - any:
        - all:
          - selector: context.request.http.path
            operator: matches
            value: ^/test/.*
          - any:
            - selector: context.request.http.method
              operator: eq
              value: POST
            - selector: context.request.http.method
              operator: eq
              value: PUT
        - selector: context.request.http.method
          operator: eq
          value: GET
      anonymous: {}
```
@guicassolato guicassolato self-assigned this Sep 20, 2023
@guicassolato guicassolato changed the title feat: OR operator for the pattern-matching (conditions and pattern-matching authorization) feat: OR operator Sep 20, 2023
@guicassolato guicassolato requested a review from a team September 21, 2023 08:38
}
}

func Any(expressions ...Expression) Expression {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so all will default to true if no experessions passed and any will default to false if no expressions passed? Is that what is intended?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has surprised more than one, but yes... See https://en.wikipedia.org/wiki/Vacuous_truth

assert.Check(t, !ok)
}

func TestEmptyOr(t *testing.T) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps being stupid, but not getting why an empty or returns false but an empty and returns true?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because every expression has 2 sides, left and right, and sometimes we instantiate expressions where the right-hand side is another empty nested expression. This is the case of expressions initialized with All()
and Any(), where we don't want the empty nested expressions to affect the result of the outer one.

A more obvious (and possibly easier to read) alternative would be defining trivial True and False expressions to use instead of the empty ones.

Additionally, see #427 (comment)

Copy link
Collaborator

@maleck13 maleck13 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes and test all look good to me, caveat I am not an expert on Authorino. One or two small questions but generally it is a looks good to me

maleck13
maleck13 previously approved these changes Sep 21, 2023
spec:
when: # auth enforced only on requests with HTTP method equals to POST or PUT
- any:
- selector: context.request.http.method
Copy link
Collaborator

@maleck13 maleck13 Sep 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side point: Have you considered and in operator as this would (at least from an API perspective) improve the verbosity.

 - selector: context.request.http.method
   operator: in
   values: ["PUT","POST"]

Copy link
Collaborator

@maleck13 maleck13 Sep 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you could also do this with regexp and the matches operator?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operator: matches
value: ^/resources/.*
- any:
- selector: context.request.http.method
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this the best example? Seems it could be achieved also with a matches operator? without too complex of a regex?

Copy link
Collaborator Author

@guicassolato guicassolato Sep 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, many cases of disjunction can be solved with the matches operator, and this is definitely true for patterns limited to finite sets (and many other infinite ones as well!), such as the values of the HTTP method. For other cases, users may want/have to go with any.

So I wanted to present any also as an alternative to matches.

Click here if you want to learn more about the limitations of the matches operator.

Because Golang regexp package (behind our matches operator) is a strict implementation of regular expressions, in the theoretical computer science sense of the term (i.e., the kind that defines a regular language), some patterns often supported by other programming languages are simply not available in Go by default.

For details, see https://github.com/google/re2/wiki/Syntax.

But that was not the only reason though. There are yet other cases where the disjunction involves patterns with different selectors. E.g.: If (method == <X>) OR (path =~ <Y>.*).

I could have used any one of those more complex cases here. I was trying to reduce the cognitive complexity of the examples by going with something simple and straightforward. I guess I didn't account for the more skilled readers, like you, who often knows a little bit more about the suject and therefore also other ways to solve the problem 🙂

- any:
- selector: context.request.http.method
operator: eq
value: POST
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a note I do find myself wanting to write this as a matches operator :)

docs/features.md Outdated Show resolved Hide resolved
Copy link
Member

@adam-cattermole adam-cattermole left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone through the verification and code and the changes here look good and make sense from my point of view

@guicassolato guicassolato merged commit d6111da into main Sep 26, 2023
9 checks passed
@guicassolato guicassolato deleted the or-conditions branch September 26, 2023 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Logical disjunction (aka "OR" operator) in conditions
4 participants