Skip to content

Commit

Permalink
feat(keycloak): simplify redeploy logic (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
shini4i authored Jan 22, 2024
1 parent 6d0c078 commit b5f55c2
Show file tree
Hide file tree
Showing 11 changed files with 298 additions and 61 deletions.
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ builds:
main: ./cmd/argo-watcher
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X server/router.version={{.Version}}
goos:
- linux
goarch:
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ test: mocks ## Run tests
.PHONY: build
build: docs ## Build the binaries
@echo "===> Building [$(CYAN)${VERSION}$(RESET)] version of [$(CYAN)argo-watcher$(RESET)] binary"
@CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o argo-watcher ./cmd/argo-watcher
@CGO_ENABLED=0 go build -ldflags="-s -w -X server/router.version=${VERSION}" -o argo-watcher ./cmd/argo-watcher
@echo "===> Done"

.PHONY: kind-upload
kind-upload:
@echo "===> Building [$(CYAN)dev$(RESET)] version of [$(CYAN)argo-watcher$(RESET)] binary"
@CGO_ENABLED=0 GOARCH=arm64 GOOS=linux go build -ldflags="-s -w -X main.version=dev" -o argo-watcher ./cmd/argo-watcher
@CGO_ENABLED=0 GOARCH=arm64 GOOS=linux go build -ldflags="-s -w -X server/router.version=dev" -o argo-watcher ./cmd/argo-watcher
@echo "===> Building web UI"
@cd web && npm run build
@echo "===> Building [$(CYAN)argo-watcher$(RESET)] docker image"
Expand Down
11 changes: 11 additions & 0 deletions cmd/argo-watcher/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package auth

type ExternalAuthService interface {
Init(url, realm, clientId string, privilegedGroups []string)
Validate(token string) (bool, error)
allowedToRollback(groups []string) bool
}

func NewExternalAuthService() ExternalAuthService {
return &KeycloakAuthService{}
}
14 changes: 14 additions & 0 deletions cmd/argo-watcher/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package auth

import (
"testing"

"github.com/stretchr/testify/assert"
)

// A super simple test to check if NewExternalAuthService returns a KeycloakAuthService
// We have it just not to lose test coverage percentage at the moment
func TestNewExternalAuthService(t *testing.T) {
service := NewExternalAuthService()
assert.IsType(t, &KeycloakAuthService{}, service)
}
91 changes: 91 additions & 0 deletions cmd/argo-watcher/auth/keycloak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package auth

import (
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/shini4i/argo-watcher/internal/helpers"

"github.com/rs/zerolog/log"
)

type KeycloakResponse struct {
Groups []string `json:"groups"`
}

type KeycloakAuthService struct {
Url string
Realm string
ClientId string
PrivilegedGroups []string
client *http.Client
}

// Init is used to initialize KeycloakAuthService with Keycloak URL, realm and client ID
func (k *KeycloakAuthService) Init(url, realm, clientId string, privilegedGroups []string) {
k.Url = url
k.Realm = realm
k.ClientId = clientId
k.PrivilegedGroups = privilegedGroups
k.client = &http.Client{}
}

// Validate implements quite simple token validation approach
// We just call Keycloak userinfo endpoint and check if it returns 200
// effectively delegating token validation to Keycloak
func (k *KeycloakAuthService) Validate(token string) (bool, error) {
var keycloakResponse KeycloakResponse

req, err := http.NewRequest("GET", fmt.Sprintf("%s/realms/%s/protocol/openid-connect/userinfo", k.Url, k.Realm), nil)
if err != nil {
return false, fmt.Errorf("error creating request: %v", err)
}
req.Header.Add("Authorization", "Bearer "+token)

resp, err := k.client.Do(req)
if err != nil {
return false, fmt.Errorf("error on response: %v", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Error().Msgf("error closing response body: %v", err)
}
}(resp.Body)

// Read and print the response body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Error().Msgf("error reading response body: %v", err)
} else {
if err := json.Unmarshal(bodyBytes, &keycloakResponse); err != nil {
log.Error().Msgf("error unmarshalling response body: %v", err)
}
}

userPrivileged := k.allowedToRollback(keycloakResponse.Groups)

if resp.StatusCode == http.StatusOK && userPrivileged {
return true, nil
} else if resp.StatusCode == http.StatusOK && !userPrivileged {
return false, fmt.Errorf("user is not a member of any of the privileged groups")
}

return false, fmt.Errorf("token validation failed with status: %v", resp.Status)
}

// allowedToRollback checks if the user is a member of any of the privileged groups
// It duplicates the logic from fronted just to be sure that the user did not generate the request manually
func (k *KeycloakAuthService) allowedToRollback(groups []string) bool {
for _, group := range groups {
if helpers.Contains(k.PrivilegedGroups, group) {
log.Debug().Msgf("User is a member of the privileged group: %v", group)
return true
}
}

log.Debug().Msgf("User is not a member of any of the privileged groups: %v", k.PrivilegedGroups)
return false
}
100 changes: 100 additions & 0 deletions cmd/argo-watcher/auth/keycloak_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package auth

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestKeycloakAuthService_Init(t *testing.T) {
service := &KeycloakAuthService{}

url := "http://localhost:8080/auth"
realm := "test"
clientId := "test"

service.Init(url, realm, clientId, []string{})

assert.Equal(t, url, service.Url)
assert.Equal(t, realm, service.Realm)
assert.Equal(t, clientId, service.ClientId)
assert.IsType(t, &http.Client{}, service.client)
}

func TestKeycloakAuthService_Validate(t *testing.T) {
t.Run("should return true if token is valid and user is in privileged group", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.Equal(t, req.URL.String(), "/realms/test/protocol/openid-connect/userinfo")
rw.WriteHeader(http.StatusOK)
_, err := rw.Write([]byte(`{"groups": ["group1"]}`))
if err != nil {
t.Error(err)
}
}))
defer server.Close()

service := &KeycloakAuthService{
Url: server.URL,
Realm: "test",
ClientId: "test",
PrivilegedGroups: []string{"group1"},
client: server.Client(),
}

ok, err := service.Validate("test")

assert.NoError(t, err)
assert.True(t, ok)
})

t.Run("should return false if token is valid but user is not in privileged group", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.Equal(t, req.URL.String(), "/realms/test/protocol/openid-connect/userinfo")
rw.WriteHeader(http.StatusOK)
_, err := rw.Write([]byte(`{"groups": ["group2"]}`))
if err != nil {
t.Error(err)
}
}))
defer server.Close()

service := &KeycloakAuthService{
Url: server.URL,
Realm: "test",
ClientId: "test",
PrivilegedGroups: []string{"group1"},
client: server.Client(),
}

ok, err := service.Validate("test")

assert.Error(t, err)
assert.False(t, ok)
})

t.Run("should return false if token is invalid", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.Equal(t, req.URL.String(), "/realms/test/protocol/openid-connect/userinfo")
rw.WriteHeader(http.StatusUnauthorized)
_, err := rw.Write([]byte(`Unauthorized`))
if err != nil {
t.Error(err)
}
}))
defer server.Close()

service := &KeycloakAuthService{
Url: server.URL,
Realm: "test",
ClientId: "test",
client: server.Client(),
}

ok, err := service.Validate("test")

assert.Error(t, err)
assert.False(t, ok)
})
}
20 changes: 19 additions & 1 deletion cmd/argo-watcher/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strconv"
"time"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/auth"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/argocd"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/prometheus"
Expand Down Expand Up @@ -33,6 +35,8 @@ type Env struct {
updater *argocd.ArgoStatusUpdater
// metrics
metrics *prometheus.Metrics
// auth service
auth auth.ExternalAuthService
}

// CreateRouter initialize router.
Expand Down Expand Up @@ -120,7 +124,21 @@ func (env *Env) addTask(c *gin.Context) {
// need to find a better way to pass the token later
deployToken := c.GetHeader("ARGO_WATCHER_DEPLOY_TOKEN")

if deployToken != "" && deployToken == env.config.DeployToken {
keycloakToken := c.GetHeader("Authorization")

// need to rewrite this block
if keycloakToken != "" {
valid, err := env.auth.Validate(keycloakToken)
if err != nil {
log.Error().Msgf("couldn't validate keycloak token, got the following error: %s", err)
c.JSON(http.StatusUnauthorized, models.TaskStatus{
Status: "You are not authorized to perform this action",
})
return
}
log.Debug().Msgf("keycloak token is validated for app %s", task.App)
task.Validated = valid
} else if deployToken != "" && deployToken == env.config.DeployToken {
log.Debug().Msgf("deploy token is validated for app %s", task.App)
task.Validated = true
} else if deployToken != "" && deployToken != env.config.DeployToken {
Expand Down
13 changes: 13 additions & 0 deletions cmd/argo-watcher/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"os"
"time"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/auth"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/argocd"

"github.com/shini4i/argo-watcher/cmd/argo-watcher/prometheus"
Expand Down Expand Up @@ -72,6 +74,17 @@ func RunServer() {
// create environment
env := &Env{config: serverConfig, argo: argo, metrics: metrics, updater: updater}

// initialize auth service
if serverConfig.Keycloak.Url != "" {
env.auth = auth.NewExternalAuthService()
env.auth.Init(
serverConfig.Keycloak.Url,
serverConfig.Keycloak.Realm,
serverConfig.Keycloak.ClientId,
serverConfig.Keycloak.PrivilegedGroups,
)
}

// start the server
log.Info().Msg("Starting web server")
router := env.CreateRouter()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/stretchr/testify v1.8.4
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.2
go.uber.org/mock v0.3.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.2.0
Expand Down Expand Up @@ -78,7 +79,6 @@ require (
github.com/prometheus/procfs v0.11.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/skeema/knownhosts v1.2.1 // indirect
github.com/swaggo/swag v1.16.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
Expand Down
Loading

0 comments on commit b5f55c2

Please sign in to comment.