Kuadrant / authorino

K8s-native AuthN/AuthZ service to protect your APIs.
Apache License 2.0
199 stars 31 forks source link

AuthConfig v1beta2 #400

Closed guicassolato closed 1 year ago

guicassolato commented 1 year ago

Package of API enhancements.

Main goals of AuthConfig v1beta2:

  1. Improve readability of the AuthConfigs / provide more seamless experience by refining terms, structures and features
  2. Prepare for/better support for Kuadrant AuthPolicy v2 (future)

The enhancements were organized in 8 groups or topics:

  1. From lists (arrays) of named definitions to maps (objects)
  2. Dynamic values, interpolation, selector expansion, and comparison values
  3. Renaming of phases of the Auth Pipeline
  4. Renaming of auth methods
  5. Restructuring of response
  6. Rule-level denyWith messages
  7. Allow/deny action (authorization.action)
  8. Gateway API's ReferenceGrants for cross-namespace references
  9. AuthConfig Composition

Some of the proposed enhancements may imply as well introduction of new features.

I'm also leaving an all-in-one example AuthConfig in the end.

The first purpose and current state of this issue is to collect feedback on the proposed enhancements, to agree on the right package of enhancements – suggestions to be refined, dropped, and new ones to add. Then we can plan on a series of iterations to achieve that, including a migration path (tools and/or automation) for users to upgrade from v1beta1.

Proposed enhancements

1. From lists (arrays) of named definitions to maps (objects)

Names are already first-class citizens in all named definitions - i.e. required in all cases. Although technically any valid string will continue to be a valid key ("object name"), intuitively this change is expected to lead to rather normalized names, thus making referencing more assertive and less error-prone.

[{name: "<name>", …}]"<name>": {…}

E.g.

metadata:
  "myRule": {…}
authorization:
  "otherRule":
    …
      valueFrom:
        authJSON: auth.metadata.myRule

is a more natural object name and reference than:

metadata:
- name: "my rule"
  …
authorization:
- name: "other rule"
  …
    valueFrom:
      authJSON: "auth.metadata.my\ rule"

Affected structures:

2. Dynamic values, interpolation, selector expansion, and comparison values

2.1. Dynamic values (or values fetched from the Authorization JSON with valueFrom.authJSON)

The valueFrom construct:

valueFrom:
  authJSON: {…}

was originally thought to be later extended as well to:

valueFrom:
  configMap: {…}
valueFrom:
  secret: {…}
valueFrom:
  file: {…}

...which never happened.

Do we want to keep this door open or make it simple, authJSON-only?

If it becomes authJSON-only, then the API could be simplifed to:

selector: path.in.the.authorization.json

2.2. Interpolation

Golang templates-inspired delimiters (i.e. {{ … }}), to improve readability especially when templating JSON content.

E.g. (assuming 2.1 above):

value: |
  {"key":"{{ auth.metadata.value }}"}

instead of (current):

valueFrom:
  authJSON: |
    \{"key":"{auth.metadata.value}"\}

2.3. Selector expansion

Just like values can be static or dynamic, selectors could be static values or paths in the Authorization JSON.

E.g. (assuming with 2.2 above)

- selector: "{{ auth.identity.group }}:{{ auth.identity.name }}"
  operator: matches
  value: "^admin:(alice|john)$"

2.4. runtime.RawExtension for selectors' comparison values

Currently selectors' comparison values are limited to string comparisons only. We want to be smart and support other operations such as in and notIn (see #357), as well as gt, lt, ge, le for numeric comparisons, with less risk of running into parsing errors.

This change may require even more inferrence on the operand type based on the operator, i.e.:

where "left side" == selector and "right side" == value.

The implementation must also be able to detect the right type of scalar: string, numeric, bool, null.

2.5. New string modifier: @split → Array

To deprecate/replace or additionally to current @extract:{sep, pos} → String.

2.6. New API and behavior for the string modifier @replace

Current: @replace:{"old": String, "new": String}
New: @replace:{<String:old>: <String:new>, …}

Alternative (to avoid the breaking change): new string modifier, e.g. @translate

3. Renaming of phases of the Auth Pipeline

3.1. identityauthentication

From the beginning, we have always been reluctant about calling the identity phase of the Auth Pipeline "authentication". The reason for that is because, strictly speaking, Authorino performs authentication only on the specific case of user/client verification based upon API keys. All other identity verification methods in Authorino would be better described as token verification or certificate verification, where these are issued not by Authorino, but by a proper authentication server. The authentication server is the actual agent doing the authentication of the user's or client application's identity.

However, we have learnt that most people will not make such distinction. Rather, they see Authorino's identity verification as the Zero Trust extension of user and client authentication. If you can't beat them... possibly you're just being stubborn. So we have been wondering whether it isn't time to embrace this simplification and finally renaming "identity" as "authentication".

Of course, the resolved identity object (post-authentication phase) will continue to be the identity object. Therefore, this change also implies rethinking the JSON path by which the identity object is accessed. Should auth.identity become auth.authentication.identity (or auth.authentication.principal)? Perhaps auth.authentication could store hints of the exact authentication rule that succeeded verifying the client's identity.

3.2. responseauthResponse

Users find "response" confusing. It is named from Authorino's perspective of building a response to an authorization request. However, that phase is often misunderstood by the response returned to the end-user or client application whose request is being handled by a gateway or proxy that checked authorization with Authorino in the middle of the process.

When Authorino uses the response phase to command mutating of the request to Envoy, this refers to the HTTP request before hitting the upstream protected service, not the HTTP response. Other possible names for the response phase could be mutation, injection, postAuthorization, or the recently suggested authResponse.

4. Renaming of auth methods

4.1. identity.oidc.jwt

jwt:
  jwksUrl: https://oidc-server/jwks

or

jwt:
  oidcDiscovery:
    issuerUrl: https://oidc-server/auth

4.2. identity.oauth2.oauth2Introspection

oauth2Introspection:
  tokenIntrospectionUrl: https://oauth2-server/token
  clientCredentialsRef:
    name: oauth-client-secret

4.3. identity.mtls.x509

x509:
  ca:
    matchLabels:
      "kuadrant/ca": "true"

4.4. identity.kubernetes.kubernetesTokenReview

4.5. identity.credentials

credentials:
  authorizationHeader:
    prefix: Bearer
credentials:
  customHeader:
    name: "x-forwarded-token"
    prefix: ""
credentials:
  queryString:
    name: "api_key"
credentials:
  cookie:
    name: "token"

4.6. identity.extendedProperties.override and .default

4.7. authorization.json.rules

4.8. authorization.kubernetes.kubernetesSubjectAccessReview

4.9. authorization.authzed.spicedb

5. Restructuring of response

5.1. wrappers as structured property

The response config would be more explicitly divided into the different kinds of wrappers, instead of wrapping being an option of each response evaluator.

E.g.:

response:
  headers: # <==== all evaluators under this wrapped as injected request headers
    "x-auth-username":
      plain: …
    "x-auth-metadata":
      json: …
    "authorization":
      wristband: …
      prefix: "Bearer "
  envoyDynamicMetadata: # <==== all evaluators under this wrapped as envoy dynamic metadata
    "rate-limit-data":
      json: …

5.2. denyWith merged into response

denyWith is also a response. Perhaps we want to fully embrace that and restructure as follows:

response:
  unauthenticated:
    code: 401
    message: Authentication failed

  unauthorized:
    code: 403
    message: Access denied

  success:
    headers:
      "x-auth-username":
        plain: …
      "x-auth-metadata":
        json: …
      "authorization":
        wristband: …
        prefix: "Bearer "
    envoyDynamicMetadata:
      "rate-limit-data":
        json: …

6. Rule-level denyWith messages

Having one single denyWithSpec for the authentication and one for the authorization is very limiting regarding the information that is provided to the user when access is denied. It should be possible to customize the response returned to the user depending on the specific authentication or authorization rule that failed to grant access.

E.g.:

identity:
  users:
    jwt: {…}
    denyWith:
      code: 302
      headers:
        location: http://auth-server/login
  serviceAccounts:
    kubernetesTokenReview: {…}
    denyWith:
      code: 401

authorization:
  myRuleA:
    opa: {…}
    denyWith:
      message: Access denied by rule A
  readOnly:
    when:
    - selector: "{{ auth.identity.groups }}"
      operator: incl
      value: admin
    rules:
    - selector: "{{ context.request.http.method }}"
      operator: in
      value: [GET, HEAD, OPTIONS, CONNECT]
    denyWith:
      message: "{{ context.request.http.method }} method is not allowed for non-admin users"

This may be a tricky one to deliver due to the fail-fast strategy under concurrent evaluators implemented by Authorino, or lead to erratic behavior perceived by the end-user.

In case of denial by multiple evaluators, Authorino would have to guess which denyWithSpec is the best to use.

7. Allow/deny action (authorization.action)

The goal is to make it easy for users to sometimes write inverted authorization rules, i.e. patterns and policies that deny access instead of allowing it.

E.g.:

authorization:
  denyList:
    rules:
    - selector: "{{ context.source.principal }}"
      operator: in
      value:
      - https://accounts.acme.com/alice
      - https://accounts.acme.com/john
    action: deny

  unauthorizedPath:
    rules:
    - selector: "{{ context.request.http.path }}"
      operator: matches
      value: "^/admin/"
    action: deny

which is equivalent to:

authorization:
  denyList:
    rules:
    - selector: "{{ context.source.principal }}"
      operator: notIn
      value:
      - https://accounts.acme.com/alice
      - https://accounts.acme.com/john
    action: allow

  unauthorizedPath:
    rules:
    - selector: "{{ context.request.http.path.@split:{\"sep\":\"/\"}.#|1 }}"
      operator: neq
      value: admin
    action: allow

TBD - how this would look like for OPA Rego policies. E.g.:

authorization:
  trivialApproval:
    opa:
      inlineRego: |
        allow = true

  trivialDenial:
    opa:
      inlineRego: |
        allow = false

  invertedApproval:
    opa:
      inlineRego: |
        allow = false
    action: deny

  invertedDenial:
    opa:
      inlineRego: |
        allow = true
    action: deny

8. Gateway API's ReferenceGrants for cross-namespace references

Currently, this would be mainly for controlling the references to Kubernetes Secrets.

Affected structures:

9. AuthConfig Composition

AuthConfigs that references other AuthConfigs by name.

All-in-one example

* User-defined property names written between double quotes to highlight the difference to API property names.

apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: toystore
  namespace: toystore
spec:
  hosts:
  - "*.toystore.com"

  when:
  - selector: "{{ context.request.http.path }}"
    operator: neq
    value: /oauth/callback

  authentication:
    "externalUsers":
      jwt:
        oidcDiscovery:
          issuerUrl: https://oidc-server/auth
        audiences:
        - toystore.com
      override:
        "quota": true
      denyWith:
        code: 302
        headers:
          "location": "https://oidc-server/login?client_id=toystore&redirect_uri=https%3A%2F%2Fwww.toystore.com%2Foauth%2Fcallback&response_type=code&scope=user%3Ainfo&state={{ context.request.http.id }}"

    "teammates":
      when:
      - selector: "{{ context.request.http.host.@split:{\"sep\":\".\"}.#|0 }}"
        operator: eq
        value: api
      kuberentesTokenReview:
        audiences:
        - toystore.com
      default:
        "username": "{{ auth.identity.sub }}"

  patterns:
    "quota":
      - selector: "{{ auth.identity.quota }}"
        operator: eq
        value: true

  metadata:
    "quotaInfo":
      when:
      - quota
      http:
        endpoint: "https://quotaservice/users/{{ auth.identity.sub }}/quota"
        sharedSecretRef:
          namespace: vault
          name: toystore
          key: quota-service
        credentials:
          customHeader:
            name: api-key

  authorization:
    "denyList":
      authConfigRef:
        namespace: cluster-policies
        name: auth
        key: authorization.denyList

    "quota":
      when:
      - quota
      rules:
      - selector: "{{ auth.metadata.quotaInfo.available }}"
        operator: gt
        value: 100
      denyWith:
        code: 429
        message: Not enough quota available to consume

    "rbac":
      kubernetesSubjectAccessReview:
        user: "{{ auth.identity.username }}"
        resourceAttributes:
          group: authorino
          resource: toystore
          verb: "{{ context.request.http.method|@replace:{\"GET\":\"read\",\"POST\":\"write\",\"PUT\":\"write\",\"DELETE\":\"write\"} }}"
      priority: 10

  authResponse:
    success:
      headers:
        "x-auth-username":
          plain: "{{ auth.identity.username }}"

    unauthorized:
      message: Access denied
---
apiVersion: gateway.networking.k8s.io/v1beta1 
kind: ReferenceGrant
metadata:
  name: toystore
  namespace: vault
spec:
  from:
  - group: authorino.kuadrant.io
    kind: AuthConfig
    namespace: toystore
  to:
  - group: ""
    kind: Secret
    name: toystore-secrets
---
apiVersion: authorino.kuadrant.io/v1beta2
kind: AuthConfig
metadata:
  name: auth
  namespace: cluster-policies
spec:
  authorization:
    "denyList":
      rules:
      - selector: "{{ auth.identity.org }}"
        operator: in
        value:
        - buy-n-large
        - lexcorp
      action: deny
---
apiVersion: gateway.networking.k8s.io/v1beta1 
kind: ReferenceGrant
metadata:
  name: toystore
  namespace: cluster-policies
spec:
  from:
  - group: authorino.kuadrant.io
    kind: AuthConfig
    namespace: toystore
  to:
  - group: authorino.kuadrant.io
    kind: AuthConfig
guicassolato commented 1 year ago

Closing as most of it was covered at #417.

Any other proposed enhancement not included in this iteration may be reopened as an independent issue later on if considered still relevant.

Enhancements left out from AuthConfig v1beta2 for now: