Skip to content

Commit

Permalink
Add auditor role (#550)
Browse files Browse the repository at this point in the history
Refactor gin routes and middlewares
Removed unused code
  • Loading branch information
sandromello authored Nov 14, 2024
1 parent 86acf6d commit c75db4f
Show file tree
Hide file tree
Showing 29 changed files with 718 additions and 784 deletions.
36 changes: 0 additions & 36 deletions gateway/api/apiroles.go

This file was deleted.

200 changes: 200 additions & 0 deletions gateway/api/apiroutes/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package apiroutes

import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/hoophq/hoop/common/apiutils"
"github.com/hoophq/hoop/common/log"
"github.com/hoophq/hoop/gateway/pgrest"
pgorgs "github.com/hoophq/hoop/gateway/pgrest/orgs"
pguserauth "github.com/hoophq/hoop/gateway/pgrest/userauth"
"github.com/hoophq/hoop/gateway/security/idp"
"github.com/hoophq/hoop/gateway/storagev2"
"github.com/hoophq/hoop/gateway/storagev2/types"
)

func (r *Router) AuthMiddleware(c *gin.Context) {
// api key authentication validation
// allow accessing all routes as admin
if apiKey := c.GetHeader("Api-Key"); apiKey != "" {
if r.registeredApiKey != apiKey {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorize"})
return
}
orgID := strings.Split(apiKey, "|")[0]
newOrgCtx := pgrest.NewOrgContext(orgID)
org, err := pgorgs.New().FetchOrgByContext(newOrgCtx)
if err != nil || org == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}

deterministicUuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(`API_KEY`))
r.setUserContext(&pguserauth.Context{
OrgID: orgID,
OrgName: org.Name,
OrgLicense: org.License,
UserUUID: deterministicUuid.String(),
UserSubject: "API_KEY",
UserName: "API_KEY",
UserEmail: "API_KEY",
UserStatus: "active",
UserGroups: []string{types.GroupAdmin},
}, c)
return
}

// jwt key authentication
subject, err := r.validateAccessToken(c)
if err != nil {
tokenHeader := c.GetHeader("authorization")
log.Infof("failed authenticating, %v, length=%v, reason=%v, url-path=%v",
parseHeaderForDebug(tokenHeader), len(tokenHeader), err, c.Request.URL.Path)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}

ctx, err := pguserauth.New().FetchUserContext(subject)
if err != nil {
log.Errorf("failed fetching user, subject=%v, err=%v", subject, err)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}
if ctx.UserStatus != string(types.UserStatusActive) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}

// it's an unregistered user, validate it via user info
routeType := routeTypeFromContext(c)
if routeType == routeUserInfoType && ctx.IsEmpty() {
uinfo, err := r.validateTokenWithUserInfo(c)
if err != nil {
log.Warnf("failed authenticating anonymous user via userinfo endpoint, err=%v", err)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}

if uinfo.Email == "" || uinfo.Subject == "" {
log.Warnf("failed authenticating unregistered user via userinfo endpoint, email=(set=%v), subject=(set=%v)",
uinfo.Email != "", uinfo.Subject != "")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}
c.Set(storagev2.ContextKey,
storagev2.NewContext("", "").
WithAnonymousInfo(uinfo.Profile, uinfo.Email, uinfo.Subject, uinfo.Picture, uinfo.EmailVerified).
WithApiURL(r.provider.ApiURL).
WithGrpcURL(r.grpcURL),
)
c.Next()
return
}

// from this point forward, the user must be authenticated and registered in the database.
if ctx.IsEmpty() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
return
}

// validate routes based on permissions from the user groups of a registered user
roles := rolesFromContext(c)
if !isGroupAllowed(ctx.UserGroups, roles...) {
log.Debugf("not allowed to access route, user=%v, path=%v, roles=%v",
ctx.UserEmail, c.Request.URL.Path, roles)
c.AbortWithStatus(http.StatusForbidden)
return
}

log.Debugf("user authenticated, roles=%v, org=%s, subject=%s, isadmin=%v, isauditor=%v",
roles, ctx.OrgName, subject, ctx.IsAdmin(), ctx.IsAuditor())
r.setUserContext(ctx, c)
}

// setUserContext and call next middleware
func (r *Router) setUserContext(ctx *pguserauth.Context, c *gin.Context) {
auditApiChanges(c, ctx)
c.Set(storagev2.ContextKey,
storagev2.NewContext(ctx.UserSubject, ctx.OrgID).
WithUserInfo(ctx.UserName, ctx.UserEmail, string(ctx.UserStatus), ctx.UserPicture, ctx.UserGroups).
WithSlackID(ctx.UserSlackID).
WithOrgName(ctx.OrgName).
WithOrgLicenseData(ctx.OrgLicenseData).
WithApiURL(r.provider.ApiURL).
WithGrpcURL(r.grpcURL),
)
c.Next()
}

// validateToken validates the access token by the user info if it's an opaque token
// or by parsing and validating the token if it's a JWT token
func (r *Router) validateAccessToken(c *gin.Context) (string, error) {
token, err := parseToken(c)
if err != nil {
return "", err
}

if r.provider.HasSecretKey() {
return r.provider.VerifyAccessTokenHS256Alg(token)
}
return r.provider.VerifyAccessToken(token)
}

// validateTokenWithUserInfo validates the access token by the user info endpoint
func (r *Router) validateTokenWithUserInfo(c *gin.Context) (*idp.ProviderUserInfo, error) {
accessToken, err := parseToken(c)
if err != nil {
return nil, err
}
return r.provider.VerifyAccessTokenWithUserInfo(accessToken)
}

func parseToken(c *gin.Context) (string, error) {
tokenHeader := c.GetHeader("authorization")
tokenParts := strings.Split(tokenHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" || tokenParts[1] == "" {
return "", errors.New("invalid authorization header")
}
return tokenParts[1], nil
}

func parseHeaderForDebug(authTokenHeader string) string {
prefixAuthHeader := "N/A"
if len(authTokenHeader) > 18 {
prefixAuthHeader = authTokenHeader[0:18]
}
bearerString, token, found := strings.Cut(authTokenHeader, " ")
if !found || bearerString != "Bearer" {
return fmt.Sprintf("isjwt=unknown, prefix-auth-header[19]=%v", prefixAuthHeader)
}
header, payload, found := strings.Cut(token, ".")
if !found {
return fmt.Sprintf("isjwt=false, prefix-auth-header[19]=%v", prefixAuthHeader)
}
headerBytes, _ := base64.StdEncoding.DecodeString(header)
payloadBytes, _ := base64.StdEncoding.DecodeString(payload)
headerBytes = bytes.ReplaceAll(headerBytes, []byte(`"`), []byte(`'`))
payloadBytes = bytes.ReplaceAll(payloadBytes, []byte(`"`), []byte(`'`))
return fmt.Sprintf("isjwt=true, header=%v, payload=%v", string(headerBytes), string(payloadBytes))
}

func auditApiChanges(c *gin.Context, ctx *pguserauth.Context) {
if c.Request.Method == "GET" || c.Request.Method == "HEAD" {
return
}
log.With(
"subject", ctx.UserSubject,
"org", ctx.OrgName,
"method", c.Request.Method,
"path", c.Request.URL.Path,
"user-agent", apiutils.NormalizeUserAgent(c.Request.Header.Values),
"content-length", c.Request.ContentLength,
).Info("api-audit")
}
60 changes: 60 additions & 0 deletions gateway/api/apiroutes/roles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package apiroutes

import (
"slices"

"github.com/gin-gonic/gin"
"github.com/hoophq/hoop/gateway/api/openapi"
"github.com/hoophq/hoop/gateway/storagev2/types"
)

const roleContextKey string = "hoop-roles"

func rolesFromContext(c *gin.Context) []openapi.RoleType {
obj, ok := c.Get(roleContextKey)
if !ok {
return nil
}
roles, _ := obj.([]openapi.RoleType)
return roles
}

// isGroupAllowed validates if the groups of a user is allowed to access a route
func isGroupAllowed(userGroups []string, roleNames ...openapi.RoleType) (valid bool) {
if slices.Contains(userGroups, types.GroupAdmin) {
// admin can access any route
return true
}

// it performs validation of route based roles
// in case the group exists it must match against a route role
for _, groupName := range userGroups {
switch groupName {
case types.GroupAuditor:
// auditor can access only assigned route roles
return slices.Contains(roleNames, openapi.RoleAuditorType)
}
}

// this condition matches against a privileged access
// and maintain the default behavior of allowing access to regular users
// that doesn't belong to any group.
//
// if a route doesn't have any role, it's also a standard access
return len(roleNames) == 0 || slices.Contains(roleNames, openapi.RoleStandardType)
}

// AdminOnlyAccessRole allows only admin users to access this role
func AdminOnlyAccessRole(c *gin.Context) {
c.Set(roleContextKey, []openapi.RoleType{openapi.RoleAdminType})
c.Next()
}

// ReadOnlyAccessRole allows standard, admin and auditor roles to access it
func ReadOnlyAccessRole(c *gin.Context) {
c.Set(roleContextKey, []openapi.RoleType{
openapi.RoleStandardType,
openapi.RoleAuditorType,
})
c.Next()
}
55 changes: 55 additions & 0 deletions gateway/api/apiroutes/roles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package apiroutes

import (
"testing"

"github.com/hoophq/hoop/gateway/api/openapi"
"github.com/hoophq/hoop/gateway/storagev2/types"
"github.com/stretchr/testify/assert"
)

func TestIsGroupAllowed(t *testing.T) {
for _, tt := range []struct {
msg string
groups []string
routeRoles []openapi.RoleType
want bool
}{
{
msg: "it should allow when the group is admin for any routes",
groups: []string{types.GroupAdmin},
routeRoles: []openapi.RoleType{openapi.RoleAuditorType},
want: true,
},
{
msg: "it should allow only when auditor role is present",
groups: []string{types.GroupAuditor},
routeRoles: []openapi.RoleType{openapi.RoleAuditorType},
want: true,
},
{
msg: "it should allow a standard access if no role is present",
groups: []string{},
routeRoles: []openapi.RoleType{},
want: true,
},
{
msg: "it should allow when standard role is present",
groups: []string{},
routeRoles: []openapi.RoleType{openapi.RoleStandardType},
want: true,
},
{
msg: "it should deny if group is not allowed",
groups: []string{"foo-group"},
routeRoles: []openapi.RoleType{openapi.RoleAdminType, openapi.RoleAuditorType},
want: false,
},
} {
t.Run(tt.msg, func(t *testing.T) {
got := isGroupAllowed(tt.groups, tt.routeRoles...)
assert.Equal(t, tt.want, got)
})
}

}
Loading

0 comments on commit c75db4f

Please sign in to comment.