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: add validation for /groups endpoints #234

Merged
merged 16 commits into from
Mar 27, 2024

Conversation

babakks
Copy link
Member

@babakks babakks commented Mar 20, 2024

This PR adds validation for /groups endpoints to the ReBAC Admin UI library. This functionality is implemented as a decorated handler that wraps/decorates the actual handler and applies validation logic.

For now, this is only done for /groups endpoints. After this has landed, follow up PRs would be submitted for the rest of endpoints.

Note that the bulk of methods in rebac-admin-backend/v1/validation.go are there to implement the interface. I could've embedded the Unimplemented struct for this, but since these were already there, I didn't change it.

Fixes CSS-7621

Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
@babakks babakks requested a review from a team as a code owner March 20, 2024 12:35
@babakks babakks requested review from alesstimec and BarcoMasile and removed request for a team March 20, 2024 12:35
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Copy link
Collaborator

@alesstimec alesstimec left a comment

Choose a reason for hiding this comment

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

LGTM with a few nitpicks

rebac-admin-backend/v1/groups.go Outdated Show resolved Hide resolved
rebac-admin-backend/v1/groups.go Outdated Show resolved Hide resolved
rebac-admin-backend/v1/groups.go Outdated Show resolved Hide resolved
rebac-admin-backend/v1/validation.go Show resolved Hide resolved
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Comment on lines 35 to 73
// getRequestBodyFromContext fetches request body from given context.
func getRequestBodyFromContext[T any](ctx context.Context) (*T, error) {
validator.New()
if body, ok := ctx.Value(requestBodyContextKey{}).(*T); ok {
return body, nil
}
return nil, NewMissingRequestBodyError("request body is not available")
}

// newRequestWithBodyInContext sets the given body in a new request instance context
// and returns the new request.
//
// Note that, technically, this method could be an ordinary (non-generic) method,
// but it's defined as one to avoid confusion over value vs pointer arguments.
func newRequestWithBodyInContext[T any](r *http.Request, body *T) *http.Request {
return r.WithContext(context.WithValue(r.Context(), requestBodyContextKey{}, body))
}

// parseRequestBody parses request body as JSON and populates the given body instance.
func parseRequestBody[T any](r *http.Request) (*T, error) {
body := new(T)
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
return nil, NewMissingRequestBodyError("request body is not a valid JSON")
}
return body, nil
}

// setRequestBodyInContext is a helper method to avoid repetition. It parses
// request body and if it's okay, will delegate to the provided callback with a
// new HTTP request instance with the parse body in the context.
func setRequestBodyInContext[T any](w http.ResponseWriter, r *http.Request, f func(w http.ResponseWriter, r *http.Request, body *T)) {
body, err := parseRequestBody[T](r)
if err != nil {
writeErrorResponse(w, err)
return
}
f(w, newRequestWithBodyInContext(r, body), body)
}
Copy link
Contributor

@shipperizer shipperizer Mar 20, 2024

Choose a reason for hiding this comment

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

this looks really smart, but i m finding it really hard to understand

the use of generics could be dropped if u offload this to each implementation in here

while for validation_utils.go generics make a lot of sense, here i m not 100% sure, especially if we read the sections in https://go.dev/blog/when-generics on when not to use

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure if I'm getting your point correctly, but I don't have a strong opinion on using generics for this part. Of course, parsing/validation step can be done without generics. For example, without generics the handler methods in the handlerWithValidation struct would look change from this:

func (v handlerWithValidation) PostGroups(w http.ResponseWriter, r *http.Request) {
  setRequestBodyInContext[resources.Group](w, r, func(w http.ResponseWriter, r *http.Request, body *resources.Group) {
    if err := validateGroup(body); err != nil {
      writeErrorResponse(w, err)
      return
    }
    v.handler.PostGroups(w, r)
  })
}

to this:

func (v handlerWithValidation) PostGroups(w http.ResponseWriter, r *http.Request) {
  body := &resources.Group{}
  setRequestBodyInContext(body, w, r, func(w http.ResponseWriter, r *http.Request) {
    if err := validateGroup(body); err != nil {
      writeErrorResponse(w, err)
      return
    }
    v.handler.PostGroups(w, r)
  })
}

Is this what you're asking for?

Copy link
Collaborator

Choose a reason for hiding this comment

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

i don't think generics are inherently bad.. i think this is a valid use case.. generics are a part of the language now.

Copy link
Contributor

Choose a reason for hiding this comment

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

the 2 bits of code above are basically the same, if we can achieve something without generics we should

it arguably makes the code harder to read, it doesn't speed it up and doesn't really bring anything to the plate apart from sugar syntax in this case

Copy link
Member Author

Choose a reason for hiding this comment

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

@alesstimec Yeah, I've seen generics used just for ensuring type safety and helping with type casting in other legit OSS projects (other languages).

Comment on lines 10 to 49
func validateGroup(value *resources.Group) error {
if len(value.Name) == 0 {
return NewRequestBodyValidationError("empty group name")
}
return nil
}

func validateGroupEntitlementsPatchRequestBody(value *resources.GroupEntitlementsPatchRequestBody) error {
if len(value.Patches) == 0 {
return NewRequestBodyValidationError("empty patch array")
}
return validateSlice[resources.GroupEntitlementsPatchItem](value.Patches, validateGroupEntitlementsPatchItem)
}

func validateGroupEntitlementsPatchItem(value *resources.GroupEntitlementsPatchItem) error {
if err := validateEntityEntitlement(&value.Entitlement); err != nil {
return err
}
return validateStringEnum("op", value.Op, resources.GroupEntitlementsPatchItemOpAdd, resources.GroupEntitlementsPatchItemOpRemove)
}

func validateEntityEntitlement(value *resources.EntityEntitlement) error {
if len(value.EntitlementType) == 0 {
return NewRequestBodyValidationError("empty entitlement type")
}
if len(value.EntityName) == 0 {
return NewRequestBodyValidationError("empty entity name")
}
if len(value.EntityType) == 0 {
return NewRequestBodyValidationError("empty entity type")
}
return nil
}

func validateGroupIdentitiesPatchRequestBody(value *resources.GroupIdentitiesPatchRequestBody) error {
if len(value.Patches) == 0 {
return NewRequestBodyValidationError("empty patch array")
}
return validateSlice[resources.GroupIdentitiesPatchItem](value.Patches, validateGroupIdentitiesPatchItem)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

this could be implemented on types generation, see oapi-codegen/oapi-codegen#227

basically using the extensions in the specific the x-oapi-codegen-extra-tags, this would add an extra yaml file, but you would have validation tags/annotations in the struct itself

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I checked the extensions, but what I don't like about them is that they clutter the spec with generator-specific tags.

Copy link
Contributor

Choose a reason for hiding this comment

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

that is fair, but at the same time right now we have types without effective embedded validation which doesn't sound ideal either

Copy link
Member Author

Choose a reason for hiding this comment

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

That's fair, too. But the solution would be to change the code generator instead of touching the spec.

Copy link
Member Author

Choose a reason for hiding this comment

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

@shipperizer In the end, we decided to go with this suggestion. There's this PR on the spec repository to add the extension tags:
canonical/openfga-admin-openapi-spec#33

Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
rebac-admin-backend/v1/response.go Show resolved Hide resolved
Comment on lines 76 to 81
func (v handlerWithValidation) GetIdentityProviders(w http.ResponseWriter, r *http.Request, params resources.GetIdentityProvidersParams) {
v.handler.GetIdentityProviders(w, r, params)
}

// PostIdentityProviders validates request body for the PostIdentityProviders method and delegates to the underlying handler.
func (v handlerWithValidation) PostIdentityProviders(w http.ResponseWriter, r *http.Request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

question + suggestion: why copying all the handlers (even the ones that don't require a body)? Since no validation is performed on GET /groups (for example) we could just rely on an embedded struct and leverage that mechanism (same way the now removed Unimplemented struct worked). This way you wouldn't need to have all these locs that just pass values to the underlying handler.

Another thing would be to validate the absence of a request body for APIs that don't use it, but we the handler would just ignore it so we're still good to go in that case.

Copy link
Member Author

Choose a reason for hiding this comment

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

You're right about the embedded struct. In the next push, I'll embed the handler so we only implement the methods we need.

rebac-admin-backend/v1/validation.go Outdated Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

My issue with this approach is that it is not a real decorator (which would avoid repetition), but it is a full on proxy, since a new implementation of all handler methods is required to do it.
I think we could do validation in one single place, thanks to the fact that we already have specific structs for each request body types (see links for examples on my proposal).

Proposal

Leveraging validator library, and a single shared var validate *validator.Validate instance (since it is thread-safe, link), we could use RegisterStructValidation method to have our custom validation functions for all the typed request bodies (as you did), without the need to add field level tags, so no need to touch generated files), just by running validate.Struct(requestBody).

After this setup, a single middleware would then be able to grab the request body (if r.method != http.MethodGet for example) and run validator.Validate on the specific type, it would just need to choose the right type based on the URL and method.
If using a single middleware makes type guards too long in the code, then we could have one middleware per resource type, but still the whole solution would be much more stramlined and simple.

Copy link
Member Author

Choose a reason for hiding this comment

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

I actually went that way a bit (except for making it as a middleware, more on that later) and faced some issues. The most obvious one is validator module is not meant to be used like this. It depends on/works with tags, and lets you have your own custom tags to implement more complex validations. I tried to use it without tags and implemented a few struct-level validators but then I noticed there's no decent error reporting mechanism for un-tagged usages. For example, this is the official example:

func UserStructLevelValidation(sl validator.StructLevel) {

	user := sl.Current().Interface().(User)

	if len(user.FirstName) == 0 && len(user.LastName) == 0 {
		sl.ReportError(user.FirstName, "fname", "FirstName", "fnameorlname", "")
		sl.ReportError(user.LastName, "lname", "LastName", "fnameorlname", "")
	}

	// plus can do more, even with different tag than "fnameorlname"
}

Here, the sl.ReportError method has the following signature:

ReportError(field interface{}, fieldName, structFieldName string, tag, param string)

As you see, it expects a tag reference. I, anyway, made a custom validator for Group structs and passed the string "cannot-be-empty" as the tag (when Group.Name is empty). When I tested the error message was:

Key: 'Group.name' Error:Field validation for 'name' failed on the 'cannot-be-empty' tag

Which is not accurate (because there's no cannot-be-empty tag around) and also not that straightforward/clear for the end user. I'm not just complaining about the error message formatting here, but my point is the library is not meant to be used like this.

Copy link
Member Author

Choose a reason for hiding this comment

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

As for the middleware suggestion, I'm against it because that means we should manually cover all the endpoints and duplicate something like the auto-generated routing. I want to use the generated code as much as possible and avoid repeating it. That's why I had to go with a decorated handler here (which uses the routing done in the auto-generated code).

Regarding this not being a true decorator, I don't think that's the case. I mean, with using an embedded struct as you suggested, the unnecessary repetitions would fade out. But the thing is validation, in essence, should apply on every request. You already pointed out that for some endpoints we need to check the absence of a request body. This means, all endpoints are actually meant to be implemented. But, for now we can skip those.

Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
Signed-off-by: Babak K. Shandiz <babak.k.shandiz@canonical.com>
@babakks babakks merged commit 6b3eb80 into feature-rebac-admin-ui-backend Mar 27, 2024
6 checks passed
@babakks babakks deleted the css-7621/add-validation branch March 27, 2024 08:32
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