From b5f55c23b3b61e0d3f2d80912dc84f63ce681c44 Mon Sep 17 00:00:00 2001 From: Vadim Gedz Date: Mon, 22 Jan 2024 14:04:18 +0200 Subject: [PATCH] feat(keycloak): simplify redeploy logic (#239) --- .goreleaser.yaml | 2 + Makefile | 4 +- cmd/argo-watcher/auth/auth.go | 11 +++ cmd/argo-watcher/auth/auth_test.go | 14 ++++ cmd/argo-watcher/auth/keycloak.go | 91 ++++++++++++++++++++++ cmd/argo-watcher/auth/keycloak_test.go | 100 +++++++++++++++++++++++++ cmd/argo-watcher/server/router.go | 20 ++++- cmd/argo-watcher/server/server.go | 13 ++++ go.mod | 2 +- web/src/Components/TaskView.js | 95 ++++++++++------------- web/src/auth.js | 7 +- 11 files changed, 298 insertions(+), 61 deletions(-) create mode 100644 cmd/argo-watcher/auth/auth.go create mode 100644 cmd/argo-watcher/auth/auth_test.go create mode 100644 cmd/argo-watcher/auth/keycloak.go create mode 100644 cmd/argo-watcher/auth/keycloak_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b85a1762..3b3f80f5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -8,6 +8,8 @@ builds: main: ./cmd/argo-watcher env: - CGO_ENABLED=0 + ldflags: + - -s -w -X server/router.version={{.Version}} goos: - linux goarch: diff --git a/Makefile b/Makefile index 125f0b73..fcb3cc67 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/cmd/argo-watcher/auth/auth.go b/cmd/argo-watcher/auth/auth.go new file mode 100644 index 00000000..4cb0cb0d --- /dev/null +++ b/cmd/argo-watcher/auth/auth.go @@ -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{} +} diff --git a/cmd/argo-watcher/auth/auth_test.go b/cmd/argo-watcher/auth/auth_test.go new file mode 100644 index 00000000..05ade402 --- /dev/null +++ b/cmd/argo-watcher/auth/auth_test.go @@ -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) +} diff --git a/cmd/argo-watcher/auth/keycloak.go b/cmd/argo-watcher/auth/keycloak.go new file mode 100644 index 00000000..79d2509b --- /dev/null +++ b/cmd/argo-watcher/auth/keycloak.go @@ -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 +} diff --git a/cmd/argo-watcher/auth/keycloak_test.go b/cmd/argo-watcher/auth/keycloak_test.go new file mode 100644 index 00000000..710fa1e6 --- /dev/null +++ b/cmd/argo-watcher/auth/keycloak_test.go @@ -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) + }) +} diff --git a/cmd/argo-watcher/server/router.go b/cmd/argo-watcher/server/router.go index d63b6b6d..1c4df166 100644 --- a/cmd/argo-watcher/server/router.go +++ b/cmd/argo-watcher/server/router.go @@ -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" @@ -33,6 +35,8 @@ type Env struct { updater *argocd.ArgoStatusUpdater // metrics metrics *prometheus.Metrics + // auth service + auth auth.ExternalAuthService } // CreateRouter initialize router. @@ -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 { diff --git a/cmd/argo-watcher/server/server.go b/cmd/argo-watcher/server/server.go index 39cb94ca..a1e034c6 100644 --- a/cmd/argo-watcher/server/server.go +++ b/cmd/argo-watcher/server/server.go @@ -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" @@ -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() diff --git a/go.mod b/go.mod index fca75e75..e6a1222a 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/web/src/Components/TaskView.js b/web/src/Components/TaskView.js index eb675d1d..c767d7cc 100644 --- a/web/src/Components/TaskView.js +++ b/web/src/Components/TaskView.js @@ -7,7 +7,6 @@ import {useNavigate, useParams} from 'react-router-dom'; import {fetchTask} from '../Services/Data'; import {useErrorContext} from '../ErrorContext'; import { - Checkbox, Chip, Dialog, DialogActions, @@ -15,10 +14,8 @@ import { DialogContentText, DialogTitle, Divider, - FormControlLabel, Grid, Paper, - TextField, } from '@mui/material'; import {chipColorByStatus, formatDateTime, ProjectDisplay, StatusReasonDisplay,} from './TasksTable'; import {AuthContext} from '../auth'; @@ -28,12 +25,23 @@ export default function TaskView() { const {id} = useParams(); const [task, setTask] = useState(null); const {setError, setSuccess} = useErrorContext(); - const {authenticated, email, groups, privilegedGroups} = useContext(AuthContext); + const {authenticated, email, groups, privilegedGroups, keycloakToken} = useContext(AuthContext); const [configData, setConfigData] = useState(null); const navigate = useNavigate(); - const [deployToken, setDeployToken] = useState(''); - const [openDeployTokenDialog, setOpenDeployTokenDialog] = useState(false); - const [showDeployToken, setShowDeployToken] = useState(false); + const [open, setOpen] = useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleConfirm = async () => { + setOpen(false); + await rollbackToVersion(); + }; useEffect(() => { fetchConfig().then(config => { @@ -74,15 +82,15 @@ export default function TaskView() { method: 'POST', headers: { 'Content-Type': 'application/json', - 'ARGO_WATCHER_DEPLOY_TOKEN': deployToken, + 'Authorization': keycloakToken, }, body: JSON.stringify(updatedTask), }); if (response.status === 401) { // HTTP 401 Unauthorized - throw new Error("Invalid deploy token!"); + throw new Error("You are not authorized to perform this action!"); } else if (response.status !== 202) { // HTTP 202 Accepted - throw new Error(`HTTP error! Status code: ${response.status}`); + throw new Error(`Received unexpected status code: ${response.status}`); } navigate('/'); @@ -91,31 +99,6 @@ export default function TaskView() { } }; - const onDeployTokenChange = (event) => { - setDeployToken(event.target.value); - }; - - const handleDeployTokenOpen = () => { - setOpenDeployTokenDialog(true); - }; - - const handleDeployTokenClose = () => { - setOpenDeployTokenDialog(false); - }; - - const confirmDeployment = async () => { - if (deployToken.trim() === '') { - setError('Deploy Token is required!'); - return; - } - handleDeployTokenClose(); - await rollbackToVersion(); - }; - - const toggleShowDeployToken = () => { - setShowDeployToken(!showDeployToken); - }; - return ( @@ -219,34 +202,34 @@ export default function TaskView() { {authenticated && userIsPrivileged && ( - // Open token input dialog on click - )} - - Enter Deploy Token + + {"Rollback Confirmation"} - - Please enter your deploy token. + + Are you sure you want to rollback to this version? - - } - label="Show Deploy Token" - /> - - + + diff --git a/web/src/auth.js b/web/src/auth.js index 3e134c28..7eafae1e 100644 --- a/web/src/auth.js +++ b/web/src/auth.js @@ -9,6 +9,7 @@ export function useAuth() { const [email, setEmail] = useState(null); const [groups, setGroups] = useState([]); const [privilegedGroups, setPrivilegedGroups] = useState([]); + const [keycloakToken, setKeycloakToken] = useState(null); useEffect(() => { fetchConfig().then(config => { @@ -26,12 +27,16 @@ export function useAuth() { setEmail(keycloak.tokenParsed.email); setGroups(keycloak.tokenParsed.groups); setPrivilegedGroups(config.keycloak.privileged_groups); + setKeycloakToken(keycloak.token); setInterval(() => { keycloak.updateToken(20) .then(refreshed => { if (refreshed) { console.log('Token refreshed, valid for ' + Math.round(keycloak.tokenParsed.exp + keycloak.timeSkew - new Date().getTime() / 1000) + ' seconds'); + // we need to set the token again here to handle cases + // when the UI was open for a long time + setKeycloakToken(keycloak.token); } }).catch(() => { console.error('Failed to refresh token'); @@ -50,5 +55,5 @@ export function useAuth() { }); }, []); - return { authenticated, email, groups, privilegedGroups }; + return { authenticated, email, groups, privilegedGroups, keycloakToken }; }