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

Alert - Support for authentication when calling receiver endpoint. #560

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from

Conversation

andreee94
Copy link

@andreee94 andreee94 commented Feb 28, 2022

The changes implement more authentication options without requiring to hard-code the secret inside the url.

Until now is not possible to call an alert webhook which requires a basic or a bearer authentication.

The secrets should not be passed directly inside the configuration, instead they can be injected as environmental variables or files.

Description

Three authentication options have been implemented:

  • basic from environmental variables
  • bearer token from environmental variables
  • bearer token from file (for example the service account token file in kubernetes)

Is however possible to specify a custom header prefix other than Basic and Bearer.

The validation schema has been updated to support the new options and the unit test has been written. All tests pass (running them inside the alpine container as suggested by the documentation).

The documentation reports a description of the new functionalities and few examples.

The new connaisseur image has been installed manually (forked from master) inside a K3S cluster and it worked correctly .

Checklist

  • PR is rebased to/aimed at branch develop
  • PR follows Contributing Guide
  • Updated schema validation (if necessary)
  • Added tests (if necessary)
  • Extended README/Documentation (if necessary)
  • Adjusted versions of image and Helm chart in values.yaml and Chart.yaml (if necessary)

Copy link
Member

@peterthomassen peterthomassen left a comment

Choose a reason for hiding this comment

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

Thanks a lot for this awesome PR! This is really useful, and also good quality.

I added a few suggestions on how to improve the code, especially structure it so that there is less repetition.

@xopham I did not review schema definitions, tests, and docs.


def __init__(self, alert_receiver_config: dict):
basic_authentication_config = alert_receiver_config.get(
"receiver_authentication_basic", None
Copy link
Member

Choose a reason for hiding this comment

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

None is the default for dict.get. Same for other occurrences.


if (
basic_authentication_config is None
): # TODO maybe remove this check since it is included in the json validation?
Copy link
Member

Choose a reason for hiding this comment

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

the check is fine

)

self.authorization_prefix = basic_authentication_config.get(
"authorization_prefix", "Basic"
Copy link
Member

Choose a reason for hiding this comment

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

Should the default be self.authorization_prefix, as the value is hardcoded at the class?

self.authorization_prefix = basic_authentication_config.get(
"authorization_prefix", "Basic"
)
# TODO maybe validate authorization prefix
Copy link
Member

Choose a reason for hiding this comment

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

Sounds like a good idea

Copy link
Author

Choose a reason for hiding this comment

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

I cannot find an actual validation rule.
The only thing I can found is: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization.
I have assumed that the authentication scheme (previously called authorization prefix) is not null or empty and does not contain any space.

token_env is not None and token_file is not None
): # TODO maybe remove this check since it is included in the json validation?
raise ConfigurationError(
"Both token_env and token_file configuration found. Only one is required."
Copy link
Member

Choose a reason for hiding this comment

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

Only one can be given.

Comment on lines 111 to 120
bearer_authentication_config = alert_receiver_config.get(
"receiver_authentication_bearer", None
)

if (
bearer_authentication_config is None
): # TODO maybe remove this check since it is included in the json validation?
raise ConfigurationError(
"No bearer authentication configuration found."
)
Copy link
Member

Choose a reason for hiding this comment

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

This is boilerplate (together with assigning self.authentication_prefix at the end of the method).

Suggestion: make a base class, introduce a variable for the lookup key (e.g. "receiver_authentication_bearer"), and run all this via super().__init__(*args, **kwargs).

Comment on lines 170 to 173
if self.is_basic():
self.__init_basic_authentication(alert_receiver_config)
elif self.is_bearer():
self.__init_bearer_authentication(alert_receiver_config)
Copy link
Member

Choose a reason for hiding this comment

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

init_map = {
    "basic": AlertReceiverAuthentication.AlertReceiverBasicAuthentication,
    "bearer": AlertReceiverAuthentication.AlertReceiverBearerAuthentication,
}
try:
    self.auth_instance = init_map[self.authentication_type](alert_receiver_config)
except AttributeError:
    ....

Then, it get_auth_header(), you don't need to do the case handling, but can just return {} if self.auth_instance is None self.auth_instance.get_header().

@@ -89,4 +235,4 @@
]
}
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

missing newline

@andreee94
Copy link
Author

@peterthomassen I have updated the alert classes accordingly to your suggestions.
Let me know if I can make something better.


class AlertReceiverAuthenticationInterface:
def __init__(self, alert_receiver_config: dict, authentication_key: str):
self.authentication_config = alert_receiver_config.get(authentication_key)
Copy link
Member

Choose a reason for hiding this comment

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

authentication_key sounds like a credential

Copy link
Author

Choose a reason for hiding this comment

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

Yes, I agree. It will be called authentication_config_key.

"The authentication scheme cannot be null or empty."
)

if " " in self.authentication_scheme:
Copy link
Member

Choose a reason for hiding this comment

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

You might want to check that it contains only a-zA-Z or something like that. (" " is very specific, doesn't catch tabs, newlines, ...)

Comment on lines 96 to 97
def get_header(self) -> dict:
return {}
Copy link
Member

Choose a reason for hiding this comment

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

This could be the default implementation (instead of @abstractmethod). (Just a thought, certainly not mandatory.)

Comment on lines 202 to 207
if self.authentication_type not in AlertReceiverAuthentication.init_map.keys():
raise ConfigurationError(
f"No authentication type found. Valid values are {list(AlertReceiverAuthentication.init_map.keys())}"
) # hopefully this never happens

def is_none(self):
return self.authentication_type == "none"

def __init_bearer_authentication(self, alert_receiver_config: dict):
self.bearer_authentication = (
AlertReceiverAuthentication.AlertReceiverBearerAuthentication(
alert_receiver_config
)
)

def __init_basic_authentication(self, alert_receiver_config: dict):
self.basic_authentication = (
AlertReceiverAuthentication.AlertReceiverBasicAuthentication(
alert_receiver_config
)
)
return self.init_map.get(self.authentication_type)
Copy link
Member

Choose a reason for hiding this comment

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

try:
    return self.init_map[self.authentication_type]
except KeyError:
    raise ConfigurationError(...)

This is a Python paradigm called EAFP. For context, see https://sauravmaheshkar.github.io/blog/Introduction-To-Duck-Typing-and-EAFP/.

One benefit is that EAFP gets ride of many TOCTOU race conditions (although not the case here, as the state is purely local).

Copy link
Author

Choose a reason for hiding this comment

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

I was not aware of this python principle. Thank you.

Copy link
Collaborator

@xopham xopham left a comment

Choose a reason for hiding this comment

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

@andreee94 real neat contribution 🚀 like the work you are contributing here 🙏

there is still a few pylint findings:

connaisseur/alert.py:102:0: C0301: Line too long (107/105) (line-too-long)
connaisseur/alert.py:127:0: C0301: Line too long (116/105) (line-too-long)
connaisseur/alert.py:205:0: C0301: Line too long (117/105) (line-too-long)
connaisseur/alert.py:94:8: W0231: __init__ method from base class 'AlertReceiverAuthenticationInterface' is not called (super-init-not-called)
connaisseur/alert.py:172:25: W1514: Using open without explicitly specifying an encoding (unspecified-encoding)
connaisseur/alert.py:175:20: W0707: Consider explicitly re-raising using the 'from' keyword (raise-missing-from)
connaisseur/alert.py:177:20: W0707: Consider explicitly re-raising using the 'from' keyword (raise-missing-from)
connaisseur/alert.py:203:43: C0201: Consider iterating the dictionary directly instead of calling .keys() (consider-iterating-dictionary)

will take a closer look at the pr once @peterthomassen is done.

Comment on lines 62 to 70
self.authentication_config = alert_receiver_config.get(
authentication_config_key
)

self.authentication_scheme = self.authentication_config.get(
"authentication_scheme", self.authentication_scheme
)
self._validate_authentication_scheme()
if self.authentication_config is None:
raise ConfigurationError(
"No authentication configuration found for dictionary key:"
f"{authentication_config_key}."
)
Copy link
Member

Choose a reason for hiding this comment

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

Here, you can also try: self.authentication_config = alert_receiver_config[authentication_config_key], and then raise the error in the except: block.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed also other places where EAFP can be applied.

Copy link
Member

@peterthomassen peterthomassen left a comment

Choose a reason for hiding this comment

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

Cool, thanks! I think the code looks great now. Let's hand over to @xopham who will think about this more from the functionality side.

Hope you didn't mind my nit-picking :-)

Comment on lines +72 to +74
self.authentication_scheme = self.authentication_config.get(
"authentication_scheme", self.authentication_scheme
)
Copy link
Member

Choose a reason for hiding this comment

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

What's the second argument here? self.authentication_scheme would be None at this point, which is the default for dict.get.

Copy link
Author

Choose a reason for hiding this comment

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

Actually it should take the default value set in the classes that inherited the base.

This is a very bad coded concept, but it should work:

class Base:
    def __init__(self):
        self.surname = self.name if self.name is not None else 'Base'
    
class A(Base):
    name = "A"
    def __init__(self):
        super().__init__()

class B(Base):
    name = "B"
    def __init__(self):
        super().__init__()

a = A()
print(a.surname)
        
b = B()
print(b.surname)

Which prints:

A
B

A proper nit-picking review is a great opportunity to improve my coding skills ;)

Copy link
Collaborator

@xopham xopham left a comment

Choose a reason for hiding this comment

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

@andreee94 thanks for contributing! Really neat work. I think reworking the authentication is definitely required for the alerting. When first introduced, it was intentionally left open via the custom_header field as authentication can be very divers and is hard to pin down without just accepting anything. However, as such any authorization header must be provided within the values.yaml which is not ideal for a secret.
Overall the alerting configuration is somewhat complex and may need reworking eventually.

I made a few comments, however I would like to discuss the general layout:

  • there is a wealth of types of authorization headers which makes this difficult to design. However, as a fallback people can always use the custom_headers field. Thus, I think we are fine restricting us here to Bearer or Basic
  • in the PR the fields receiver_authentication_type, receiver_authentication_bearer and receiver_authentication_basic are introduced which has redundancy in the authentication type (essentially, specifying receiver_authentication_bearer already defines the receiver_authentication_type which is thus obsolete)
  • there is two ways how secrets can be specified:
    • via environment variables: while better than plain in the values.yaml, this is still not a native way of providing a secret in Kubernetes. The intentional solution would be a kubernetes secret. If we are to improve the security, we should go for the intended handling of secrets.
    • via the service account token: Unless there is concrete use-case (e.g. intentional usage of the account token in a managed kubernetes as a workload identity), I would caution against this, as (1) in the current form this allows accessing a random path on the pod and (2) this would exfiltrate the service account token that has a much broader permission out of the scope of the cluster in question.
  • the field names seem long in the current form

Providing a concise, clear and compatible form seems to be very challenging and this is further complicated by the complex configuration of alerting. I would propose using the following form:

alerting:
  admit_request:
    templates:
      - template: ecs-1-12-0 
        receiver_url: https://your.custom.domain.com/webhook/admit
        authorization_header:
          scheme: Bearer  # scheme (e.g. `Bearer`, `Basic`) used in authorization header
          # either (preferred)
          secret_name: alladins-secret  # specify the name of a Kubernetes secret to be used. The secret take the following form: `auth: base64(opensesame)` (in case of basic auth use base64(username:password)
          # or
          auth: opensesame  # secret to add after the scheme in the authorization header (in case of basic auth use base64(username:password)

The proposal is similar to the auth for validators. It reduces redundancy, avoids having to parse username and password that are provided as a single token anyways and allows either rendering the auth field in during deployment via helm install ... -set ... or employing an actual kubernetes secret that must be configured separately. In case, the validation for scheme was chosen as any string containing only letters and '-' with at maximum 20 characters, this would also allow more exotic auth headers such as OpsGenie's "authorization: GenieKey reallybadchoicetobeuniqueinaworldofstandars`. The kubernetes secret could still be added to Connaisseur as an env variable.

I apologize for the long response, however it took me some time to wrap my head around this and I am still not certain whether this is the optimal solution. Let me know what you think 🙏

@@ -52,6 +61,54 @@ alerting:
receiver_url: https://bots.keybase.io/webhookbot/<Your-Keybase-Hook-Token>
```

## Example With Authentication
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd prefer to make this a new subsection of additional notes

docs/features/alerting.md Show resolved Hide resolved
token_env: CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN
```

You then have to set the `CONNAISSEUR_ADMIT_REQUEST_WEBHOOK_AUTH_TOKEN` environment variable referencing the bearer token secret you want to use into the connaisseur deployment.
Copy link
Collaborator

Choose a reason for hiding this comment

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

to understand this correctly, one would have to provide the secret here and below via the .deployment.envs, right in helm/values.yaml or se them up manually in the kubernetes connaisseur namespace, right? If that is so, we should specifically point those options out here and below.

Copy link
Author

@andreee94 andreee94 Mar 5, 2022

Choose a reason for hiding this comment

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

Actually I am modifying the deployment.yamlfile in the helm chart to include the possibility to load envs from secrets:

          {{- if .Values.deployment.env }}
            {{- range $e := .Values.deployment.env }}
            - name: {{ $e.name }}
              value: {{ $e.value }}
            {{- end }}
          {{- end }}
          {{- if .Values.deployment.envValueFrom}}
            {{- range $e := .Values.deployment.envValueFrom }}
            - name:  {{ $e.name }}
              valueFrom:
                {{- if $e.valueFrom.secretKeyRef }}
                secretKeyRef:
                  name: {{ $e.valueFrom.secretKeyRef.name }}
                  key: {{ $e.valueFrom.secretKeyRef.key }}
                {{- else if $e.valueFrom.configMapKeyRef }}
                configMapKeyRef:
                  name: {{ $e.valueFrom.configMapKeyRef.name }}
                  key: {{ $e.valueFrom.configMapKeyRef.key }}
                {{- else if $e.valueFrom.fieldRef }}
                fieldRef:
                  fieldPath: {{ $e.valueFrom.fieldRef.fieldPath }}
                {{- end }}
            {{- end }}
          {{- end }}
deployment:
  # Add the environmental variables to the pod in the deployment
  # NOT use this for secrets, use the envValueFrom section instead
  env:
    - name: CONNAISSEUR_ENV
      value: "admin"

  # Add environmental variables to the pod in the deployment
  # referencing secrets, configMaps, or fields.
  envValueFrom:
    - name: CONNAISSEUR_WEBHOOK_USERNAME
      valueFrom:
        secretKeyRef:
          name: connaisseur-webhook-user
          key: username
    - name: CONNAISSEUR_WEBHOOK_PASSWORD
      valueFrom:
        secretKeyRef:
          name: connaisseur-webhook-user
          key: password

I will commit the code shortly.

The .deployment.envs option is fine for non secrets environmental variables.

receiver_url: https://your.custom.domain.com/webhook/admit
receiver_authentication_type: bearer
receiver_authentication_bearer:
token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is certainly an option, but I would consider that somewhat bad practice to misuse the service account token that has a much broader scope than authentication of the alerting webhook. One would technically be sending a kubernetes secret out of the cluster. What would be the exact use-case of this? Is there a situation where this token would be a reasonable secret? Is there a managed k8s provider where such an authentication is possible?

Copy link
Collaborator

Choose a reason for hiding this comment

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

besides, this is somewhat guessing where a file will be and will only really work for the serviceaccount token. as such it should then not be a file but a boolean whether to use the service account token or not.
Or how would one get a file with a secret into the container?

Copy link
Author

Choose a reason for hiding this comment

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

This is just an example of loading the token from the service account. Since the service account token is just a file inside the pod I prefer not to explicitly have a bool to control this behavior but to let it generic.

For what concern the service account as authentication method, I am not sure if it makes sense to use it in a production environment with the webhook inside the cluster (To not let the token to leak outside).
I have just tried it to experiment with the TokenReview and the system:auth-delegator cluster role.

Taking the token from a file may be used in general if the secret has been mounted as file inside the pod.

To make the use case more clear I can change the example to something more generic like /etc/webhook/token.

| `alerting.<category>.receiver_authentication_bearer` | object | - | only when `receiver_authentication_type` is `bearer` | Authentication credentials for bearer authentication. |
| `alerting.<category>.receiver_authentication_bearer.token_env` | string | - | only when `receiver_authentication_type` is `bearer` | Token Environmental variable for bearer authentication (Exclusive with `token_file`). |
| `alerting.<category>.receiver_authentication_bearer.token_file` | string | - | only when `receiver_authentication_type` is `bearer` | Token file for bearer authentication (Exclusive with `token_env`). |
| `alerting.<category>.receiver_authentication_bearer.authentication_scheme` | string (without spaces) | `Bearer` | | Prefix for Authorization header for bearer authentication. |
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd propose to reference the subsection below for details. see e.g. here

@@ -57,6 +58,76 @@
"template": "custom",
}

receiver_config_bearer_env = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

the added tests seem to not test several of the added configurationerrors above. Pytest claims the following lines are not being tested:

$ pytest tests --cov-report term-missing --cov connaisseur
(...)
Name                                                    Stmts   Miss  Cover   Missing
-------------------------------------------------------------------------------------
(...)
connaisseur/alert.py                                      181     11    94%   66-67, 115-116, 151, 158, 177-178, 205, 285-286
(...)

Copy link
Author

@andreee94 andreee94 Mar 5, 2022

Choose a reason for hiding this comment

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

Most of the not tested lines are part of the code that should not be reached since the validation is performed with the schema validation:

  • 66-67
  • 115-116
  • 151
  • 158
  • 205

This is in case the file reading fails for unknown reason (FileNotFoundError tested separately).
The approach mirrors the code already used to load a file in lines 277-288:

  • 177-178

These are lines not concerning this pull request:

  • 241-243
  • 285-286


Or if you would like to use the service account token as the bearer token, you can use the following snippet:

```
Copy link
Collaborator

Choose a reason for hiding this comment

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

here and in other cases should be denoted as YAML

Suggested change
```
```yaml

@codecov-commenter
Copy link

Codecov Report

Merging #560 (6aa67ab) into develop (165f518) will increase coverage by 2.64%.
The diff coverage is 96.47%.

@@             Coverage Diff             @@
##           develop     #560      +/-   ##
===========================================
+ Coverage    93.52%   96.17%   +2.64%     
===========================================
  Files           15       22       +7     
  Lines          633     1254     +621     
===========================================
+ Hits           592     1206     +614     
- Misses          41       48       +7     
Impacted Files Coverage Δ
connaisseur/__main__.py 0.00% <0.00%> (ø)
connaisseur/kube_api.py 87.50% <66.66%> (ø)
...naisseur/validators/notaryv2/notaryv2_validator.py 80.00% <80.00%> (ø)
connaisseur/flask_application.py 92.63% <92.63%> (ø)
connaisseur/validators/static/static_validator.py 93.75% <93.75%> (ø)
connaisseur/alert.py 93.92% <93.92%> (ø)
connaisseur/util.py 95.45% <95.45%> (-4.55%) ⬇️
connaisseur/logging_wrapper.py 96.15% <96.15%> (ø)
connaisseur/config.py 97.29% <97.29%> (ø)
connaisseur/workload_object.py 98.24% <98.24%> (ø)
... and 12 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 0cc2957...6aa67ab. Read the comment docs.

Copy link
Collaborator

@xopham xopham left a comment

Choose a reason for hiding this comment

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

@andreee94 sorry for taking so long with feedback. I was very busy and we had to fix a few bugs with high priority recently. I hope to get back to this next week.

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.

4 participants