diff --git a/.ci/setup_kong_ee.sh b/.ci/setup_kong_ee.sh index c07bfbbc7..d080a12a4 100755 --- a/.ci/setup_kong_ee.sh +++ b/.ci/setup_kong_ee.sh @@ -64,11 +64,17 @@ readonly GATEWAY_CONTAINER_NAME=kong KONG_ROUTER_FLAVOR=${KONG_ROUTER_FLAVOR:-'traditional_compatible'} KONG_CUSTOM_PLUGIN_STREAMING_ENABLED=${KONG_CUSTOM_PLUGIN_STREAMING_ENABLED:-'on'} +KONG_ENFORCE_RBAC=${KONG_ENFORCE_RBAC:-'off'} +if [[ "${KONG_ENFORCE_RBAC}" == "on" ]]; then + KONG_PASSWORD=${KONG_PASSWORD:-'kong_admin_token'} +fi +KONG_PASSWORD=${KONG_PASSWORD:-''} initNetwork initDb initMigrations "${KONG_IMAGE}" \ - -e "KONG_LICENSE_DATA=${KONG_LICENSE_DATA}" + -e "KONG_LICENSE_DATA=${KONG_LICENSE_DATA}" \ + -e "KONG_PASSWORD=${KONG_PASSWORD}" # Start Kong Gateway EE docker run \ @@ -83,6 +89,8 @@ docker run \ -e "MY_SECRET_KEY=$MY_SECRET_KEY" \ -e "KONG_ROUTER_FLAVOR=${KONG_ROUTER_FLAVOR}" \ -e "KONG_CUSTOM_PLUGIN_STREAMING_ENABLED=${KONG_CUSTOM_PLUGIN_STREAMING_ENABLED}" \ + -e "KONG_ENFORCE_RBAC=${KONG_ENFORCE_RBAC}" \ + -e "KONG_PASSWORD=${KONG_PASSWORD}" \ -p 8000:8000 \ -p 8443:8443 \ -p 8001:8001 \ diff --git a/.github/workflows/integration-enterprise.yaml b/.github/workflows/integration-enterprise.yaml index d4de82fd1..4b53bf3f8 100644 --- a/.github/workflows/integration-enterprise.yaml +++ b/.github/workflows/integration-enterprise.yaml @@ -72,3 +72,53 @@ jobs: KONG_LICENSE_DATA: ${{ steps.license.outputs.license }} run: make test-integration continue-on-error: ${{ matrix.kong_image == 'kong/kong-gateway-dev:latest' }} + + integration-rbac: + # Skip if the PR has the 'skip-ee' label + if: ${{ github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'skip-ee') }} + timeout-minutes: ${{ fromJSON(vars.DECK_WORKFLOW_GW_TIMEOUT || '10') }} + strategy: + matrix: + kong_image: + - 'kong/kong-gateway:2.8' + - 'kong/kong-gateway:3.4' + - 'kong/kong-gateway:3.10' + - 'kong/kong-gateway:3.14' + - 'kong/kong-gateway-dev:latest' + env: + KONG_ANONYMOUS_REPORTS: "off" + KONG_IMAGE: ${{ matrix.kong_image }} + KONG_ENFORCE_RBAC: 'on' + KONG_PASSWORD: 'kong_admin_token' + KONG_ADMIN_TOKEN: 'kong_admin_token' + + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Setup go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - name: Login to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef + with: + username: ${{secrets.DOCKER_ORG_NAME}} + password: ${{secrets.DOCKER_ORG_TOKEN}} + - uses: Kong/kong-license@master + id: license + with: + op-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + - name: Setup Kong + env: + KONG_LICENSE_DATA: ${{ steps.license.outputs.license }} + run: make setup-kong-ee + - name: Run RBAC admin-token integration test + env: + KONG_LICENSE_DATA: ${{ steps.license.outputs.license }} + run: make test-integration GOTESTFLAGS='-run Test_RBAC_AdminToken' + continue-on-error: ${{ matrix.kong_image == 'kong/kong-gateway-dev:latest' }} diff --git a/cmd/root.go b/cmd/root.go index dcc07d1f2..c11f1e951 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -211,6 +211,13 @@ It can be used to export, import, or sync entities to Kong.`, rootCmd.MarkFlagsMutuallyExclusive("konnect-runtime-group-name", "konnect-control-plane-name") + rootCmd.PersistentFlags().String("kong-admin-token", "", + "Token to use for authentication with Kong's Admin API.\n"+ + "This value can also be set using DECK_KONG_ADMIN_TOKEN "+ + "environment variable.") + viper.BindPFlag("kong-admin-token", + rootCmd.PersistentFlags().Lookup("kong-admin-token")) + rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newCompletionCmd()) rootCmd.AddCommand(newSyncCmd(true)) // deprecated, to exist under the `gateway` subcommand only @@ -316,7 +323,11 @@ func initConfig() { tlsSkipVerify := viper.GetBool("tls-skip-verify") tlsCACert := caCertContent - rootConfig.Headers = extendHeaders(viper.GetStringSlice("headers")) + rootConfig.Headers = extendHeaders( + viper.GetStringSlice("headers"), + header{name: "Kong-Admin-Token", value: viper.GetString("kong-admin-token")}, + ) + rootConfig.SkipWorkspaceCrud = viper.GetBool("skip-workspace-crud") rootConfig.Debug = (viper.GetInt("verbose") >= 1) rootConfig.Timeout = (viper.GetInt("timeout")) @@ -417,10 +428,40 @@ func initKonnectConfig() error { return nil } -func extendHeaders(headers []string) []string { - userAgentHeader := fmt.Sprintf("User-Agent:decK/%s", VERSION) - headers = append(headers, userAgentHeader) - return headers +type header struct { + name string + value string +} + +func extendHeaders(headers []string, extra ...header) []string { + result := make([]string, 0, len(headers)+len(extra)+1) + result = append(result, fmt.Sprintf("User-Agent:decK/%s", VERSION)) + + for _, h := range headers { + name, _, _ := strings.Cut(h, ":") + // skip --headers entries already set by a dedicated flag to avoid header collisions + if !overriddenBy(name, extra) { + result = append(result, h) + } + } + + for _, h := range extra { + if h.value != "" { + result = append(result, fmt.Sprintf("%s:%s", h.name, h.value)) + } + } + return result +} + +// reports whether the given header name collides with an explicit header set by a dedicated flag +func overriddenBy(name string, extra []header) bool { + name = strings.TrimSpace(name) + for _, h := range extra { + if h.value != "" && strings.EqualFold(name, h.name) { + return true + } + } + return false } func init() { diff --git a/tests/integration/rbac_admin_token_test.go b/tests/integration/rbac_admin_token_test.go new file mode 100644 index 000000000..8371de1b0 --- /dev/null +++ b/tests/integration/rbac_admin_token_test.go @@ -0,0 +1,78 @@ +//go:build integration + +package integration + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_RBAC_AdminToken exercises the --kong-admin-token flag (added to +// authenticate against an RBAC-enabled Kong Admin API). +// +// It only runs against an Enterprise Kong instance with RBAC enforcement +// enabled, which is provided by the dedicated `integration-rbac` job in +// .github/workflows/integration-enterprise.yaml. When RBAC is enforced, a +// command that talks to the Admin API must fail without a valid admin token +// and succeed once the token is supplied. +func Test_RBAC_AdminToken(t *testing.T) { + runWhenRBAC(t, ">=2.8.0") + + // disable analytics for integration tests + t.Setenv("DECK_ANALYTICS", "off") + + // The CI job seeds the kong_admin token in KONG_ADMIN_TOKEN; fall back to + // the decK CLI variable for local runs. + adminToken := os.Getenv("KONG_ADMIN_TOKEN") + if adminToken == "" { + adminToken = os.Getenv("DECK_KONG_ADMIN_TOKEN") + } + require.NotEmpty(t, adminToken, + "KONG_ADMIN_TOKEN or DECK_KONG_ADMIN_TOKEN must be set when running RBAC tests") + + // online validation hits the Admin API but does not mutate state, so no + // reset/cleanup (which would itself require the token) is needed. + const stateFile = "testdata/validate/kong-ee.yaml" + + t.Run("fails when kong-admin-token is not passed", func(t *testing.T) { + // make sure the CLI cannot pick the token up from the environment, so + // the request reaches Kong unauthenticated. + t.Setenv("DECK_KONG_ADMIN_TOKEN", "") + + err := validate(ONLINE, stateFile) + require.Error(t, err, + "online validate should fail against an RBAC-enabled Kong without an admin token") + // Require that it fails *specifically* because of authentication (HTTP 401) + // rather than some unrelated error (bad file, gateway down, etc.). + // go-kong formats API errors as `HTTP status 401 (message: ...)`. + require.Contains(t, err.Error(), "401", + "expected an authentication failure (HTTP 401), got: %v", err) + }) + + t.Run("succeeds when kong-admin-token is passed", func(t *testing.T) { + // scrub the env so the token can only come from the flag, proving the + // flag is what authenticates the request. + t.Setenv("DECK_KONG_ADMIN_TOKEN", "") + + err := validate(ONLINE, stateFile, "--kong-admin-token", adminToken) + require.NoError(t, err, + "online validate should succeed against an RBAC-enabled Kong with a valid admin token") + }) + + t.Run("kong-admin-token takes precedence over a colliding --headers value", func(t *testing.T) { + // scrub the env so the token can only come from the flags under test. + t.Setenv("DECK_KONG_ADMIN_TOKEN", "") + + // Supply an invalid Kong-Admin-Token via --headers alongside the valid + // token via --kong-admin-token. The explicit flag must win, so the + // invalid header value is dropped and the request authenticates. + err := validate(ONLINE, stateFile, + "--headers", "Kong-Admin-Token:invalid-token", + "--kong-admin-token", adminToken) + require.NoError(t, err, + "online validate should succeed: --kong-admin-token must override the "+ + "colliding Kong-Admin-Token supplied via --headers") + }) +} diff --git a/tests/integration/test_utils.go b/tests/integration/test_utils.go index 28b7490b1..d701bad7e 100644 --- a/tests/integration/test_utils.go +++ b/tests/integration/test_utils.go @@ -119,6 +119,12 @@ func runWhenExpressions(t *testing.T, semverRange string) { kong.RunWhenKongRouterFlavor(t, "expressions") } +func runWhenRBAC(t *testing.T, semverRange string) { + t.Helper() + skipWhenKonnect(t) + kong.RunWhenEnterprise(t, semverRange, kong.RequiredFeatures{RBAC: true}) +} + func sortSlices(x, y interface{}) bool { var xName, yName string switch xEntity := x.(type) {