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

Fixes #36972 - Make hammer accept unlimited as JWT life time #9951

Merged
merged 1 commit into from
Jan 15, 2024

Conversation

girijaasoni
Copy link
Contributor

@girijaasoni girijaasoni commented Dec 11, 2023

No description provided.

@theforeman-bot
Copy link
Member

Can one of the admins verify this patch?

2 similar comments
@theforeman-bot
Copy link
Member

Can one of the admins verify this patch?

@theforeman-bot
Copy link
Member

Can one of the admins verify this patch?

@sayan3296
Copy link
Contributor

As a person of interest, I wanted to ask something. Right now the jwt_expiration flag accepts both integers as well as the unlimited string but that raises some concern as explained in theforeman/foreman-ansible-modules#1683 (comment) .

Is there an alternative way to generate the token for unlimited time using some numeric\interger inputs instead of using unlimited string ?

Copy link
Contributor

@stejskalleos stejskalleos left a comment

Choose a reason for hiding this comment

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

As @sayan3296 mentioned, going with :number only would be probably better and safer way to go.
Plus we could (should?) unify the behavior with UI controller as well.

@@ -15,7 +15,7 @@ class RegistrationCommandsController < V2::BaseController
param :smart_proxy_id, :number, desc: N_("ID of the Smart Proxy. This Proxy must have enabled both the 'Templates' and 'Registration' features")
param :setup_insights, :bool, desc: N_("Set 'host_registration_insights' parameter for the host. If it is set to true, insights client will be installed and registered on Red Hat family operating systems")
param :setup_remote_execution, :bool, desc: N_("Set 'host_registration_remote_execution' parameter for the host. If it is set to true, SSH keys will be installed on the host")
param :jwt_expiration, :number, desc: N_("Expiration of the authorization token (in hours)")
param :jwt_expiration, String, desc: N_("Expiration of the authorization token (in hours)")
Copy link
Contributor

Choose a reason for hiding this comment

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

This should allow both: number and string as well. But I think we should go only with :number only, with following logic:

  • x < 0 raise an error, invalid value
  • x = 0 unlimited expiration
  • x > 0 expiration in hours.

This should be also described in the documentation

Copy link
Contributor

Choose a reason for hiding this comment

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

I forgot about the default value, if not set , default value should be 4 (same as in the UI)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That makes sense @stejskalleos , thank you

Copy link
Member

Choose a reason for hiding this comment

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

What about leaving negative numbers as valid, but treat them as unlimited expiration? I mean, 0 can be used for test purposes to simulate expired tokens without a need to actually wait.

Copy link
Contributor

Choose a reason for hiding this comment

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

In that case, perhaps
x < -1 raises an error, invalid value
x = 0 expired
x = -1 Unlimited token
x > 0 expiration in hours.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed with @ares on our 1:1, it's a good idea to verify with the QEs if they actually use 0 for testing purposes (from UI) , can double check for api as well, accordingly we can either use '-1' or '0' and incline UI in the similar manner. I will also verify the convention with the reporter and update the PR.

@pablomh
Copy link

pablomh commented Dec 11, 2023

I get the following when testing this patch:

ERROR: unauthorized
JWT SSO: Expired JWT token.

@girijaasoni
Copy link
Contributor Author

I get the following when testing this patch:

ERROR: unauthorized
JWT SSO: Expired JWT token.

That's weird. It could be because the API is modified to accept strings as well.

@@ -14,7 +14,7 @@ import LabelIcon from '../../../../../components/common/LabelIcon';
import { sprintf, translate as __ } from '../../../../../common/I18n';

const TokenLifeTime = ({ value, onChange, handleInvalidField, isLoading }) => {
const minValue = 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the use-case for it? Why would the user want to create a registration command with an expired token?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think so too, I spoke with QAs, they do not use it for testing either. I think it would be safe to use '0' to represent 'unlimited'
@pablomh what is your opinion on using 0 for unlimited instead of -1?

Copy link

Choose a reason for hiding this comment

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

I agree with @stejskalleos and I think that we should avoid accepting an "expired token option" and settle on:

  • x < 0 raises an error, invalid value
  • x = 0 Unlimited token
  • x > 0 expiration in hours

@sayan3296, opinions?

Copy link
Contributor

Choose a reason for hiding this comment

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

I apologize for the late reply and that sounds fine to me as long as everyone else agrees.

@@ -33,11 +33,13 @@ def command_headers
}

if registration_params['jwt_expiration'].present?
jwt_args[:expiration] = registration_params['jwt_expiration'].to_i.hours.to_i if registration_params['jwt_expiration'] != 'unlimited'
jwt_args[:expiration] = registration_params['jwt_expiration'].to_i.hours.to_i if registration_params['jwt_expiration'] != 'unlimited' && registration_params['jwt_expiration'].to_i != -1
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not readable much, maybe the case statement here would be better.
Also why to have the condition for the unlimited, that should be changed to -1 right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The value of jwt_args[:expiration] must be nil when the token value is 'unlimited' (from the UI) or -1 from the api or hammer so this line is basically not assigning anything to jwt_args[:expiration] when the either of them is the case.
We can also convert 'unlimited' (coming from the UI) to -1 and then send nil to jwt_args[:expiration] when the case is -1 but that's just an extra step. Whatever works.
I agree with the case statements. I can try that

@@ -15,7 +15,7 @@ class RegistrationCommandsController < V2::BaseController
param :smart_proxy_id, :number, desc: N_("ID of the Smart Proxy. This Proxy must have enabled both the 'Templates' and 'Registration' features")
param :setup_insights, :bool, desc: N_("Set 'host_registration_insights' parameter for the host. If it is set to true, insights client will be installed and registered on Red Hat family operating systems")
param :setup_remote_execution, :bool, desc: N_("Set 'host_registration_remote_execution' parameter for the host. If it is set to true, SSH keys will be installed on the host")
param :jwt_expiration, :number, desc: N_("Expiration of the authorization token (in hours)")
param :jwt_expiration, :number, desc: N_("Expiration of the authorization token (in hours), -1 means 'unlimited'.")
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we explicitly mention all the possibilities? -2..,-1,0, 1, max value(999999)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To align it with UI, its a good idea to set the max value to 999999.

@pablomh
Copy link

pablomh commented Dec 20, 2023

Current patch works in my testing environment.

if is_token_valid
jwt_args[:expiration] = registration_params['jwt_expiration'].to_i.hours.to_i unless unlimited
else
raise ArgumentError, _("Error: Option --jwt-expiration: The value must be between 0 to 999999 hours. 0 means 'unlimited'.")
Copy link
Member

Choose a reason for hiding this comment

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

Some nitpicks:

  • The API has no option --jwt-expiration, that's a hammer thing. I think here we should talk about the jwt_expiration parameter.
  • I think it's more correct to raise a Foreman::Exception like this: raise ::Foreman::Exception.new(N_("the text")) (see https://projects.theforeman.org/projects/foreman/wiki/Translating#Exceptions)
  • Why 999999? This seems like an arbitrary number without any meaning? It translates to roughly 114 years which seems rather long. We either should have a "sensible" limit, like a year or two. Or no limit at all (my preference).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you so much @evgeni , I'll update the exception, Foreman exception is definitely more valid.
I used 999999 because that's the limit set from the UI validations I think when are conventionally accepting unlimited, then 999999 hours(which is still limited) might make sense. I could remove the limit all together but that would not incline with the UI.

@@ -33,7 +44,11 @@ def command_headers
}

if registration_params['jwt_expiration'].present?
jwt_args[:expiration] = registration_params['jwt_expiration'].to_i.hours.to_i if registration_params['jwt_expiration'] != 'unlimited'
if expiration_valid?
jwt_args[:expiration] = registration_params['jwt_expiration'].to_i.hours.to_i unless expiration_unlimited?
Copy link
Member

Choose a reason for hiding this comment

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

Can we extract the registration_params['jwt_expiration'] into a method, to make the jwt_expiration constant appear once?
Something like this:

def jwt_expiration_param
  # use require if the parameter is mandatory or permit if it's optional.
  param = registration_params.require('jwt_expiration') || 4
  @jwt_expiration_param ||= begin
    if param == 'unlimited'
      0
    else
      Integer(param)
    end
end

Then the expiration_unlimited would not have to handle with string values at all:

def def expiration_unlimited?
  jwt_expiration_param == 0
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks Shim. This was more readable.
A few points:

  • permit method returns the value as a Parameter object. So I'm unable to use to_i or Integer on it
  • I have used param.to_i instead of Integer(param) because it would break the code if we input a decimal value from the UI (which currently is converted to greatest integer with the to_i method). Also integer validation is automatically happening for hammer and api here with the number validator

Copy link
Member

@evgeni evgeni Dec 27, 2023

Choose a reason for hiding this comment

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

Sadly, we do not enable Apipie validations in Foreman (for 11 years now: 5d78633 -- this is why the UI can pass unlimited, even if the api says it's a number):

# TODO enable?
config.validate = false

Some clients (e.g. FAM) still use the values in the APIDoc to enforce validation, but that's up to the clients.

if param == 'unlimited'
0
else
param.to_i
Copy link
Member

Choose a reason for hiding this comment

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

This implementation will not fail validation of strings other than unlimited.

Since you have to accept decimal values, you can still have it validated:

Suggested change
param.to_i
Float(param).to_i

This will accept 11.5, but will fail for unknown words.

ShimShtein
ShimShtein previously approved these changes Jan 4, 2024
Copy link
Contributor

@stejskalleos stejskalleos left a comment

Choose a reason for hiding this comment

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

Two nits

@@ -27,15 +30,33 @@ def registration_url(proxy = nil)
"#{url}/register"
end

def jwt_expiration_param
param = registration_params['jwt_expiration'] || 4
Copy link
Contributor

Choose a reason for hiding this comment

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

If we have constants for MIN_VALUE and MAX_VALUE, shouldn't we have also a constant for the default value?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

MIN_VALUE and MAX_VALUE are used twice (in expiration_valid? and in the error message)
We're using default value only once

if param == 'unlimited'
0
else
Float(param).to_i
Copy link
Contributor

Choose a reason for hiding this comment

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

We should catch an error when user sends invalid data directly to the API:

curl -X POST "http://localhost:3000/api/registration_commands" \
     -H "Content-Type: application/json" \
     --user admin:changeme \
     -d '{"registration_command": {"jwt_expiration": "unlimiteded"}}'

=>

{
  "error": {"message":"invalid value for Float(): \"unlimiteded\""}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Could you elaborate more? I tested this and as far as I know , we are already catching the error. Am I missing something?

Copy link
Contributor

Choose a reason for hiding this comment

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

Well yes, but the error is kinda programmatic, not user-friendly.
Instead of invalid value for Float(): \"unlimiteded\" we can raise Invalid value %s for jwt_expiration. The value must be between %s and %s. 0 means 'unlimited'.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

validation is done in the following order :

  1. Float() is to invalidate Strings except 'unlimited'
  2. Invalid value %s for jwt_expiration. The value must be between 0 and 999999. 0 means 'unlimited'. is basically to invalidate negative numbers

I think it makes sense to have 2 different errors for 2 different types of validations. I could however try to update it but that could mean changing the order(which is a lot of modification) or I could pass -1 as a value for strings, if it's absolutely required

Copy link
Contributor

Choose a reason for hiding this comment

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

We probably talk about two different things, but my main concern is the message itself.
As a user, I don't want to get an invalid value for Float() error, I want to get easy to follow, descriptive error message, like The value must be between 0 and 999999. 0 means 'unlimited'.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1

A single and easily understandable error message is always preferable when you consider the user-experience for our product.

The value must be between 0 and 999999. Here 0 represents 'unlimited'. --> This sounds great to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @stejskalleos, @sayan3296 , I was unaware that we can set Float exceptions to false. Updated it :D

Copy link
Contributor

@stejskalleos stejskalleos left a comment

Choose a reason for hiding this comment

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

I like the code, LGTM, just lacks the test coverage. Can you add the following tests:

  • With expiration value below 0 (-1)
  • With expiration value above max value
  • Trying to generate command with nonsense string

With this test coverage, we should be safe.

@girijaasoni
Copy link
Contributor Author

I like the code, LGTM, just lacks the test coverage. Can you add the following tests:

  • With expiration value below 0 (-1)
  • With expiration value above max value
  • Trying to generate command with nonsense string

With this test coverage, we should be safe.

Nice catch @stejskalleos , updated it

Copy link
Contributor

@stejskalleos stejskalleos left a comment

Choose a reason for hiding this comment

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

🍏 LGTM

Works as expected, nice test coverage, let's get this in.

Thanks @girijaasoni and everyone involved!

@stejskalleos stejskalleos merged commit 670ee16 into theforeman:develop Jan 15, 2024
15 checks passed
@@ -27,17 +31,40 @@ def registration_url(proxy = nil)
"#{url}/register"
end

def invalid_expiration_error
raise ::Foreman::Exception.new(N_("Invalid value '%s' for jwt_expiration. The value must be between %s and %s. 0 means 'unlimited'."), registration_params['jwt_expiration'], MIN_VALUE, MAX_VALUE)
Copy link
Member

Choose a reason for hiding this comment

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

This broke translations:

MALFORMED: "Invalid value '%s' for jwt_expiration. The value must be between %s and %s. 0 "

I think this is because you can only use %s once. More than that and it becomes ambiguous.

#10176

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
9 participants