diff --git a/.github/workflows/ci_oci-env-integration.yml b/.github/workflows/ci_oci-env-integration.yml index 045ae54fef..87472ab733 100644 --- a/.github/workflows/ci_oci-env-integration.yml +++ b/.github/workflows/ci_oci-env-integration.yml @@ -24,6 +24,7 @@ jobs: - TEST_PROFILE: iqe_rbac - TEST_PROFILE: x_repo_search - TEST_PROFILE: community + - TEST_PROFILE: dab_jwt runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 6b59ffd207..4066188f81 100644 --- a/Makefile +++ b/Makefile @@ -142,6 +142,10 @@ gh-action/standalone: gh-action/community: python3 dev/oci_env_integration/actions/community.py +.PHONY: gh-action/dab_jwt +gh-action/dab_jwt: + python3 dev/oci_env_integration/actions/dab_jwt.py + .PHONY: gh-action/certified-sync gh-action/certified-sync: python3 dev/oci_env_integration/actions/certified-sync.py @@ -309,3 +313,7 @@ oci/community: .PHONY: oci/dab oci/dab: dev/oci_start dab + +.PHONY: oci/dab_jwt +oci/dab_jwt: + dev/oci_start dab_jwt diff --git a/dev/oci_env_integration/actions/action_lib.py b/dev/oci_env_integration/actions/action_lib.py index 8be2df9dca..22d5e5808f 100644 --- a/dev/oci_env_integration/actions/action_lib.py +++ b/dev/oci_env_integration/actions/action_lib.py @@ -121,11 +121,18 @@ def run_test(self): time.sleep(wait_time) if self.envs[env]["run_tests"]: - self.exec_cmd( - env, - "exec bash /src/galaxy_ng/profiles/base/run_integration.sh" - f" {pytest_flags} {self.flags}" - ) + if self.envs[env].get("test_script"): + self.exec_cmd( + env, + f"exec bash {self.envs[env]['test_script']}" + f" {pytest_flags} {self.flags}" + ) + else: + self.exec_cmd( + env, + "exec bash /src/galaxy_ng/profiles/base/run_integration.sh" + f" {pytest_flags} {self.flags}" + ) def dump_logs(self): if not self.do_dump_logs: diff --git a/dev/oci_env_integration/actions/dab_jwt.py b/dev/oci_env_integration/actions/dab_jwt.py new file mode 100644 index 0000000000..7877f7a793 --- /dev/null +++ b/dev/oci_env_integration/actions/dab_jwt.py @@ -0,0 +1,12 @@ +import action_lib + +env = action_lib.OCIEnvIntegrationTest( + envs=[ + { + "env_file": "dab_jwt.compose.env", + "run_tests": True, + "db_restore": None, + "test_script": "/src/galaxy_ng/profiles/dab_jwt/run_integration.sh" + } + ] +) diff --git a/dev/oci_env_integration/oci_env_configs/dab_jwt.compose.env b/dev/oci_env_integration/oci_env_configs/dab_jwt.compose.env new file mode 100644 index 0000000000..68d92b67ea --- /dev/null +++ b/dev/oci_env_integration/oci_env_configs/dab_jwt.compose.env @@ -0,0 +1,20 @@ +COMPOSE_PROFILE=galaxy_ng/base:galaxy_ng/dab_jwt +COMPOSE_PROJECT_NAME=ci-dab-proxy + +DEV_SOURCE_PATH=galaxy_ng +COMPOSE_BINARY=docker +SETUP_TEST_DATA=0 +UPDATE_UI=0 +ENABLE_SIGNING=1 +HUB_API_ROOT=http://jwtproxy:8080/api/galaxy + +DJANGO_SUPERUSER_USERNAME=admin +DJANGO_SUPERUSER_PASSWORD=admin + +PULP_GALAXY_API_PATH_PREFIX=/api/galaxy +PULP_ANALYTICS=false + +# proxy config ... +API_PORT=5001 +JWT_PROXY_PORT=8080 + diff --git a/galaxy_ng/tests/integration/dab/__init__.py b/galaxy_ng/tests/integration/dab/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/galaxy_ng/tests/integration/dab/test_url_resolution.py b/galaxy_ng/tests/integration/dab/test_url_resolution.py new file mode 100644 index 0000000000..1292e25d19 --- /dev/null +++ b/galaxy_ng/tests/integration/dab/test_url_resolution.py @@ -0,0 +1,28 @@ +import os +import pytest + + +@pytest.mark.deployment_standalone +@pytest.mark.skipif( + not os.getenv("ENABLE_DAB_TESTS"), + reason="Skipping test because ENABLE_DAB_TESTS is not set" +) +def test_dab_collection_download_url_hostnames(settings, galaxy_client, published): + """ + We want the download url to point at the gateway + """ + gc = galaxy_client("admin") + cv_url = 'v3/plugin/ansible/content/published/collections/index/' + cv_url += f'{published.namespace}/{published.name}/versions/{published.version}' + cv_info = gc.get(cv_url) + download_url = cv_info['download_url'] + assert download_url.startswith(gc.galaxy_root) + + # try to GET the tarball ... + dl_resp = gc.get(download_url, parse_json=False) + assert dl_resp.status_code == 200 + assert dl_resp.headers.get('Content-Type') == 'application/gzip' + + # make sure the final redirect was through the gateway ... + expected_url = gc.galaxy_root.replace('/api/galaxy/', '') + assert dl_resp.url.startswith(expected_url) diff --git a/galaxy_ng/tests/integration/utils/iqe_utils.py b/galaxy_ng/tests/integration/utils/iqe_utils.py index fc02fb8b13..ca5d292431 100755 --- a/galaxy_ng/tests/integration/utils/iqe_utils.py +++ b/galaxy_ng/tests/integration/utils/iqe_utils.py @@ -685,8 +685,11 @@ def get_hub_version(ansible_config): gc = GalaxyKitClient(ansible_config).gen_authorized_client(role) except GalaxyError: # FIXME: versions prior to 4.7 have different credentials. This needs to be fixed. - gc = GalaxyClient(galaxy_root="http://localhost:5001/api/automation-hub/", + api_root = os.environ.get("HUB_API_ROOT", "http://localhost:5001/api/automation-hub/") + api_root = api_root.rstrip("/") + "/" + gc = GalaxyClient(galaxy_root=api_root, auth={"username": "admin", "password": "admin"}) + return gc.get(gc.galaxy_root)["galaxy_ng_version"] diff --git a/profiles/dab_jwt/README.md b/profiles/dab_jwt/README.md new file mode 100644 index 0000000000..f84453526b --- /dev/null +++ b/profiles/dab_jwt/README.md @@ -0,0 +1,5 @@ +# galaxy_ng/dab + +## Usage + +This profile is used for running Galaxy NG with the JWT authentication integrations provided by django-ansible-base \ No newline at end of file diff --git a/profiles/dab_jwt/compose.yaml b/profiles/dab_jwt/compose.yaml new file mode 100644 index 0000000000..9fc49a866b --- /dev/null +++ b/profiles/dab_jwt/compose.yaml @@ -0,0 +1,12 @@ +--- +services: + jwtproxy: + build: + context: "{SRC_DIR}/galaxy_ng/profiles/dab_jwt/proxy" + ports: + - "{JWT_PROXY_PORT}:{JWT_PROXY_PORT}" + environment: + UPSTREAM_URL: "http://pulp:{API_PORT}" + PROXY_PORT: "{JWT_PROXY_PORT}" + volumes: + - "{SRC_DIR}/galaxy_ng/profiles/dab_jwt/proxy:/app:rw" diff --git a/profiles/dab_jwt/profile_requirements.txt b/profiles/dab_jwt/profile_requirements.txt new file mode 100644 index 0000000000..cc6421d168 --- /dev/null +++ b/profiles/dab_jwt/profile_requirements.txt @@ -0,0 +1 @@ +galaxy_ng/base \ No newline at end of file diff --git a/profiles/dab_jwt/proxy/.air.toml b/profiles/dab_jwt/proxy/.air.toml new file mode 100644 index 0000000000..e22fc5b027 --- /dev/null +++ b/profiles/dab_jwt/proxy/.air.toml @@ -0,0 +1,9 @@ +[build] +bin = "proxy" +cmd = "go build -o proxy proxy.go" +full_bin = "./proxy" + +exclude_regex = ["*.swp"] + +[run] +cmd = "./proxy" diff --git a/profiles/dab_jwt/proxy/Dockerfile b/profiles/dab_jwt/proxy/Dockerfile new file mode 100644 index 0000000000..08076b985b --- /dev/null +++ b/profiles/dab_jwt/proxy/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:alpine + +# hot reloads ... +RUN go install github.com/cosmtrek/air@latest + +RUN mkdir -p /app +WORKDIR /app + +CMD ["air"] diff --git a/profiles/dab_jwt/proxy/go.mod b/profiles/dab_jwt/proxy/go.mod new file mode 100644 index 0000000000..8dd8987e1a --- /dev/null +++ b/profiles/dab_jwt/proxy/go.mod @@ -0,0 +1,8 @@ +module mockproxy + +go 1.16 + +require ( + github.com/golang-jwt/jwt/v4 v4.4.1 + github.com/google/uuid v1.6.0 // indirect +) diff --git a/profiles/dab_jwt/proxy/go.sum b/profiles/dab_jwt/proxy/go.sum new file mode 100644 index 0000000000..e3808450d0 --- /dev/null +++ b/profiles/dab_jwt/proxy/go.sum @@ -0,0 +1,4 @@ +github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/profiles/dab_jwt/proxy/proxy.go b/profiles/dab_jwt/proxy/proxy.go new file mode 100644 index 0000000000..992b45159c --- /dev/null +++ b/profiles/dab_jwt/proxy/proxy.go @@ -0,0 +1,347 @@ +/************************************************************* + + DAB JWT Proxy + + Given a galaxy_ng stack that is configured to enable + ansible_base.jwt_consumer.hub.auth.HubJWTAuth from + an upstream proxy, this script serves as that proxy. + + The clients use basic auth to talk to the proxy, + and then the proxy replaces their authorization header + with a JWT before passing it on to the galaxy system. + The galaxy backend decrypts and decodes the token + to determine the username, email, first, last, teams, + and groups. + + If the client tries to auth via a token, the proxy + should not alter the authorization header and instead + pass it unmodified to galaxy. This presumably keeps + backwards compatibility for ansible-galaxy cli clients + which have been configured to use django api tokens. + +*************************************************************/ + +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" + + "crypto/hmac" + "crypto/sha256" + "encoding/json" + "errors" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" +) + +// User represents a user's information +type User struct { + Username string + Password string + FirstName string + LastName string + IsSuperuser bool + Email string + Organizations map[string]interface{} + Teams []string + IsSystemAuditor bool +} + +// JWT claims +type UserClaims struct { + Iss string `json:"iss"` + Aud string `json:"aud"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + IsSuperuser bool `json:"is_superuser"` + Email string `json:"email"` + Sub string `json:"sub"` + Claims map[string]interface{} `json:"claims"` + IsSystemAuditor bool `json:"is_system_auditor"` + jwt.RegisteredClaims +} + +var ( + rsaPrivateKey *rsa.PrivateKey + rsaPublicKey *rsa.PublicKey +) + +func init() { + // Generate RSA keys + var err error + rsaPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + rsaPublicKey = &rsaPrivateKey.PublicKey +} + +func getEnv(key string, fallback string) string { + if key, ok := os.LookupEnv(key); ok { + return key + } + return fallback +} + +func pathHasPrefix(path string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func generateHmacSha256SharedSecret(nonce *string) (string, error) { + + const ANSIBLE_BASE_SHARED_SECRET = "redhat1234" + var SharedSecretNotFound = errors.New("The setting ANSIBLE_BASE_SHARED_SECRET was not set, some functionality may be disabled") + + if ANSIBLE_BASE_SHARED_SECRET == "" { + log.Println("The setting ANSIBLE_BASE_SHARED_SECRET was not set, some functionality may be disabled.") + return "", SharedSecretNotFound + } + + if nonce == nil { + currentNonce := fmt.Sprintf("%d", time.Now().Unix()) + nonce = ¤tNonce + } + + message := map[string]string{ + "nonce": *nonce, + "shared_secret": ANSIBLE_BASE_SHARED_SECRET, + } + + messageBytes, err := json.Marshal(message) + if err != nil { + return "", err + } + + mac := hmac.New(sha256.New, []byte(ANSIBLE_BASE_SHARED_SECRET)) + mac.Write(messageBytes) + signature := fmt.Sprintf("%x", mac.Sum(nil)) + + secret := fmt.Sprintf("%s:%s", *nonce, signature) + return secret, nil +} + +// BasicAuth middleware +func BasicAuth(next http.Handler, users map[string]User) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + prefixes := []string{"/v2", "/token"} + + path := r.URL.Path + auth := r.Header.Get("Authorization") + log.Printf("Authorization: %s", auth) + + lowerAuth := strings.ToLower(auth) + /* + if auth == "" || strings.HasPrefix(lowerAuth, "token") || strings.HasPrefix(lowerAuth, "bearer"){ + // no auth OR token auth should go straight to the downstream ... + log.Printf("skip jwt generation") + } else { + */ + + if strings.HasPrefix(lowerAuth, "basic") && !pathHasPrefix(path, prefixes) { + + const basicPrefix = "Basic " + if !strings.HasPrefix(auth, basicPrefix) { + log.Printf("Unauthorized2") + http.Error(w, "Unauthorized2", http.StatusUnauthorized) + return + } + + decoded, err := base64.StdEncoding.DecodeString(auth[len(basicPrefix):]) + if err != nil { + log.Printf("Unauthorized3") + http.Error(w, "Unauthorized3", http.StatusUnauthorized) + return + } + + credentials := strings.SplitN(string(decoded), ":", 2) + fmt.Printf("credentials %s\n", credentials) + if len(credentials) != 2 { + log.Printf("Unauthorized4") + http.Error(w, "Unauthorized4", http.StatusUnauthorized) + return + } + + user, exists := users[credentials[0]] + if !exists || user.Password != credentials[1] { + log.Printf("Unauthorized5") + http.Error(w, "Unauthorized5", http.StatusUnauthorized) + return + } + + // Generate the JWT token + token, err := generateJWT(user) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Set the X-DAB-JW-TOKEN header + r.Header.Set("X-DAB-JW-TOKEN", token) + + // Remove the Authorization header + r.Header.Del("Authorization") + } + + next.ServeHTTP(w, r) + }) +} + +// generateJWT generates a JWT for the user +func generateJWT(user User) (string, error) { + claims := UserClaims{ + Iss: "ansible-issuer", + Aud: "ansible-services", + Username: user.Username, + FirstName: user.FirstName, + LastName: user.LastName, + IsSuperuser: user.IsSuperuser, + Email: user.Email, + Sub: user.Username, + Claims: map[string]interface{}{ + "organizations": user.Organizations, + "teams": user.Teams, + }, + IsSystemAuditor: user.IsSystemAuditor, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "ansible-issuer", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(rsaPrivateKey) +} + +// jwtKeyHandler handles requests to /api/gateway/v1/jwt_key/ +func jwtKeyHandler(w http.ResponseWriter, r *http.Request) { + pubKeyBytes, err := x509.MarshalPKIXPublicKey(rsaPublicKey) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + pubKeyPem := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, + }) + + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(pubKeyPem) +} + +func main() { + + // Define users + users := map[string]User{ + "admin": { + Username: "admin", + Password: "admin", + FirstName: "ad", + LastName: "min", + IsSuperuser: true, + Email: "admin@example.com", + Organizations: map[string]interface{}{}, + Teams: []string{}, + IsSystemAuditor: true, + }, + "notifications_admin": { + Username: "notifications_admin", + Password: "redhat", + FirstName: "notifications", + LastName: "admin", + IsSuperuser: true, + Email: "notifications_admin@example.com", + Organizations: map[string]interface{}{}, + Teams: []string{}, + IsSystemAuditor: true, + }, + "jdoe": { + Username: "jdoe", + Password: "redhat", + FirstName: "John", + LastName: "Doe", + IsSuperuser: false, + Email: "john.doe@example.com", + Organizations: map[string]interface{}{ + "org1": "Organization 1", + "org2": "Organization 2", + }, + Teams: []string{}, + IsSystemAuditor: false, + }, + } + + // listen port + proxyPort := getEnv("PROXY_PORT", "8080") + + // downstream host + target := getEnv("UPSTREAM_URL", "http://localhost:5001") + + // verify the url + url, err := url.Parse(target) + if err != nil { + panic(err) + } + + // instantiate the proxy + proxy := httputil.NewSingleHostReverseProxy(url) + + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + // log every reqest + log.Printf("Request: %s %s", req.Method, req.URL.String()) + + // just assume this proxy is http ... + req.Header.Add("X-Forwarded-Proto", "https") + + // https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-envoy-internal + req.Header.Add("X-Envoy-Internal", "true") + + // each request has a unique ID + newUUID := uuid.New() + req.Header.Add("X-Request-Id", newUUID.String()) + + // make the x-trusted-proxy header + newSecret, _ := generateHmacSha256SharedSecret(nil) + req.Header.Add("X-Trusted-Proxy", newSecret) + + originalDirector(req) + } + + proxy.ModifyResponse = func(resp *http.Response) error { + // TODO: add any relevant headers to the response + //resp.Header.Add("X-Proxy-Response-Header", "Header-Value") + return nil + } + + // serve /api/gateway/v1/jwt_key/ from this service so the client can + // get the decryption keys for the jwts + http.HandleFunc("/api/gateway/v1/jwt_key/", jwtKeyHandler) + + // send everything else downstream + http.Handle("/", BasicAuth(proxy, users)) + + fmt.Printf("Starting proxy server on :%s\n", proxyPort) + if err := http.ListenAndServe(fmt.Sprintf(":%s", proxyPort), nil); err != nil { + panic(err) + } +} diff --git a/profiles/dab_jwt/proxy/tmp/main b/profiles/dab_jwt/proxy/tmp/main new file mode 100755 index 0000000000..2785b4d2af Binary files /dev/null and b/profiles/dab_jwt/proxy/tmp/main differ diff --git a/profiles/dab_jwt/pulp_config.env b/profiles/dab_jwt/pulp_config.env new file mode 100644 index 0000000000..60452153d5 --- /dev/null +++ b/profiles/dab_jwt/pulp_config.env @@ -0,0 +1,18 @@ +PULP_GALAXY_AUTHENTICATION_CLASSES="['galaxy_ng.app.auth.session.SessionAuthentication', 'ansible_base.jwt_consumer.hub.auth.HubJWTAuth', 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.BasicAuthentication']" + +PULP_ANSIBLE_BASE_JWT_VALIDATE_CERT=false +PULP_ANSIBLE_BASE_JWT_KEY=http://jwtproxy:8080 +PULP_CONTENT_ORIGIN="http://jwtproxy:8080" +PULP_CSRF_TRUSTED_ORIGINS = ['http://jwtproxy:8080'] + +PULP_GALAXY_FEATURE_FLAGS__dab_resource_registry=true +PULP_GALAXY_FEATURE_FLAGS__external_authentication=true + +PULP_TOKEN_SERVER="http://jwtproxy:8080/token/" + +# ease-of-use +ENABLE_SIGNING=1 +PULP_GALAXY_AUTO_SIGN_COLLECTIONS=true +PULP_GALAXY_REQUIRE_CONTENT_APPROVAL=true +PULP_GALAXY_COLLECTION_SIGNING_SERVICE=ansible-default +PULP_GALAXY_CONTAINER_SIGNING_SERVICE=container-default diff --git a/profiles/dab_jwt/run_integration.sh b/profiles/dab_jwt/run_integration.sh new file mode 100644 index 0000000000..ba49600d1b --- /dev/null +++ b/profiles/dab_jwt/run_integration.sh @@ -0,0 +1,30 @@ +set -e + +VENVPATH=/tmp/gng_testing +PIP=${VENVPATH}/bin/pip +source $VENVPATH/bin/activate + +cd /src/galaxy_ng/ + +django-admin shell < ./dev/common/setup_test_data.py +cd galaxy_ng +django-admin makemessages --all + +cd /src/galaxy_ng/ + + + +set -x + +export HUB_API_ROOT=http://jwtproxy:8080/api/galaxy/ +export HUB_ADMIN_PASS=admin +export HUB_USE_MOVE_ENDPOINT=1 +export HUB_LOCAL=1 +export ENABLE_DAB_TESTS=1 +export HUB_TEST_MARKS="deployment_standalone" + +#$VENVPATH/bin/pytest -v -r sx --color=yes "$@" galaxy_ng/tests/integration/dab +$VENVPATH/bin/pytest -v -r sx --color=yes -m "$HUB_TEST_MARKS" "$@" galaxy_ng/tests/integration +RC=$? + +exit $RC