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

Recommendation for Handler-specific Inline Middleware #202

Closed
sm3142 opened this issue Jan 8, 2024 · 7 comments
Closed

Recommendation for Handler-specific Inline Middleware #202

sm3142 opened this issue Jan 8, 2024 · 7 comments

Comments

@sm3142
Copy link
Contributor

sm3142 commented Jan 8, 2024

I'm somewhat stumped on how to implement handler-specific (aka inline) middleware with Huma. My specific use case is for fine-grained role-based access control, where each endpoint may have a different RBAC profile.

Here's an example of what I mean (dumbed down for simplicity's sake). The code is using Chi, but the principle should be general enough.

package main

import (
    "net/http"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func RequireRole(role string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // In the real world, the role would obviously be determined from a JWT token,
            // using an authorization introspection or some such mechanism.
            role := "admin"

            if role != "admin" {
                w.WriteHeader(http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Get("/public", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("public"))
    })

    // Use chi.With() to apply middleware to a subrouter
    r.With(RequireRole("admin")).Get("/admin", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("admin"))
    })

   http.ListenAndServe(":3000", r)
}

I can't for the life of me figure out how to translate this to Huma, where endpoint registration is abstracted away from the actual router implementation.

My current workaround (which naturally always works) is to embed the authorization check in the body of the endpoint operation itself. However, I'm wondering if there's a more canonical way to do the same thing with middleware. I also suppose the greater question really is whether and how the concept of router composition (i.e. for Chi the likes of the .With(), .Route(), .Group(), .Mount() methods) is applicable in Huma context.

I concede that it's quite possible I'm barking up the wrong tree here because I just don't get it™️. Either way, any advise comes highly appreciated.

PS:thank you for the amazing framework!

@Insei
Copy link
Contributor

Insei commented Jan 8, 2024

@sm3142, Hi! For now, it's not possible, but for you purpose, i can give you simple example how to make global authorization midleware, that handles only if it's needed by security restriction for each endpoint.

  1. Write you own huma router-agnostic middleware for security scheme reading and jwt parsing:
NewAuthMiddleware() {
        return func(ctx huma.Context, next func(huma.Context)) {
                var anyOfNeededRoles []string
                isAuthorizationRequired := false
		for _, opScheme := range ctx.Operation().Security {
			var ok bool
			if anyOfNeededRoles, ok = opScheme["Bearer"]; ok {
				isAuthorizationRequired = true
			}
		}
		// check isAuthorizationRequired flag, 
		// if true - 
		//     parser jwt token from jwt header, get roles, check roles, 
		//         if neded roles exist in jwt - handle next
		//         if no - write error to context with `huma.WriteErr` and not call next()
		// if false - simply call next()
	}
}
  1. Add Security Scheme and Middleware to huma
defconfig := huma.DefaultConfig("Some API V1", "1.0.0")
defconfig.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
  "Bearer": {
	  Type:         "http",
	  Scheme:       "bearer",
	  BearerFormat: "JWT",
  },
}
api := humachi.New(chiMux, defconfig)|
api.UseMiddleware(NewAuthMiddleware())
  1. Add security to huma.Operation in huma.Register() func.
huma.Register(
  api,
  huma.Operation{
	  Summary:     "Delete for admins only",
	  Method:      http.MethodDelete,
	  Path:        "/api/v2/admin/",
	  Security: []map[string][]string{
		  {"Bearer": {"admin"}},
	  },
	  Errors: []int{
		  http.StatusUnauthorized,
		  http.StatusForbidden,
		  http.StatusBadRequest,
	  },
  },
  func(ctx context.Context, input *request) (*struct{}, error) {
	  // this request will only be processed if the user is authorized and has the required roles
  },
)

This is a very simple example, but I hope it helps you use the middlewares in Huma.

@Insei
Copy link
Contributor

Insei commented Jan 8, 2024

With OAuth2: How to document the enpoints with jwt authentication, but the principle is the same =)

@sm3142
Copy link
Contributor Author

sm3142 commented Jan 9, 2024

@Insei thank you for the detailed explanation! Exactly what I was looking for. It somehow didn't occur to me to use the OpenAPI security spec in this way - likely because I was fixated on the router composition pattern exemplified by Chi (and others), trying to fit that particular square peg into the round hole.

If I may make a humble suggestion: I think the recipe above deserves its own how-to entry - I'm sure it would be helpful for others as well.

Anyway, thanks again for the great work with Huma!

@Insei
Copy link
Contributor

Insei commented Jan 9, 2024

@sm3142 You are welcome! I think soon I will add a universal auth JWT middleware into huma, with the ability to choose access control by roles, claims or scopes, i already have a working solution, but its not ideal for now

@danielgtaylor
Copy link
Owner

@Insei @sm3142 I tried to put some of this info together into an initial how-to. It could use some feedback before I merge, please take a look at https://github.com/danielgtaylor/huma/pull/204/files?short_path=bb47c3b#diff-bb47c3bf4e16beb2e9b64f7d9a38cb4dd921384c2137d3586b856fb1cbc3dcd2

@JanRuettinger
Copy link

I have a follow up question regarding the usual next step: retrieving the user object from the database.

The proposed solution makes sure that the operation handler is only executed if the request is authorized but how do we know what user triggered the request.

huma.Register(
  api,
  huma.Operation{
	  Summary:     "Delete for admins only",
	  Method:      http.MethodDelete,
	  Path:        "/api/v2/admin/",
	  Security: []map[string][]string{
		  {"Bearer": {"admin"}},
	  },
	  Errors: []int{
		  http.StatusUnauthorized,
		  http.StatusForbidden,
		  http.StatusBadRequest,
	  },
  },
  func(ctx context.Context, input *request) (*struct{}, error) {
	  // this request will only be processed if the user is authorized and has the required roles
          // GET USER FROM DB
  },
)

I see two potential solutions:

  1. Add the required information about a user, e.g. user_id, as an additional input parameter to the request. What I don't like about this approach is that it adds redundant information since the Bearer token already contains the user_id.

  2. Use a resolver function to make the user available in the operation handler as shown here

Is there another approach which is more canonical?

@danielgtaylor
Copy link
Owner

@JanRuettinger sorry for the delay I was dealing with covid and then catching up at work. Your two approaches are canonical and reasonable to me. What we wound up doing at work is having JWT decoding at the gateway and info like the user ID becomes a simple header which we take as an input parameter on the calls that need it. Other teams have directly taken the JWT at the service and used a resolver to parse it into its constituent fields for the audience, issuer, user, scopes/perms, etc.

As the original question was answered I'm going to close this, but feel free to open additional issues with more questions.

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

No branches or pull requests

4 participants