Currently used by cloud scheduler service account + */ +public class ServiceAccountAuthenticationMechanism extends IdTokenAuthenticationBase { + + private final String cloudSchedulerEmailPrefix; + private static final String BEARER_PREFIX = "Bearer "; + + @Inject + public ServiceAccountAuthenticationMechanism( + @ServiceAccount TokenVerifier tokenVerifier, + @Config("cloudSchedulerServiceAccountEmail") String cloudSchedulerEmailPrefix) { + + super(tokenVerifier); + this.cloudSchedulerEmailPrefix = cloudSchedulerEmailPrefix; + } + + @Override + String rawTokenFromRequest(HttpServletRequest request) { + String rawToken = request.getHeader(AUTHORIZATION); + if (rawToken != null && rawToken.startsWith(BEARER_PREFIX)) { + return rawToken.substring(BEARER_PREFIX.length()); + } + return null; + } + + @Override + AuthResult authResultFromEmail(String emailAddress) { + if (emailAddress.equals(cloudSchedulerEmailPrefix)) { + return AuthResult.create(APP); + } else { + return AuthResult.NOT_AUTHENTICATED; + } + } +} diff --git a/core/src/test/java/google/registry/request/auth/ServiceAccountAuthenticationMechanismTest.java b/core/src/test/java/google/registry/request/auth/ServiceAccountAuthenticationMechanismTest.java new file mode 100644 index 000000000..db6a34c32 --- /dev/null +++ b/core/src/test/java/google/registry/request/auth/ServiceAccountAuthenticationMechanismTest.java @@ -0,0 +1,75 @@ +// Copyright 2023 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.request.auth; + +import static com.google.common.net.HttpHeaders.AUTHORIZATION; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.json.webtoken.JsonWebSignature.Header; +import com.google.auth.oauth2.TokenVerifier; +import javax.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ServiceAccountAuthenticationMechanismTest { + + @Mock private TokenVerifier tokenVerifier; + @Mock private HttpServletRequest request; + + private JsonWebSignature token; + private ServiceAccountAuthenticationMechanism serviceAccountAuthenticationMechanism; + + @BeforeEach + void beforeEach() throws Exception { + serviceAccountAuthenticationMechanism = + new ServiceAccountAuthenticationMechanism(tokenVerifier, "sa-prefix@email.com"); + when(request.getHeader(AUTHORIZATION)).thenReturn("Bearer jwtValue"); + Payload payload = new Payload(); + payload.setEmail("sa-prefix@email.com"); + token = new JsonWebSignature(new Header(), payload, new byte[0], new byte[0]); + when(tokenVerifier.verify("jwtValue")).thenReturn(token); + } + + @Test + void testSuccess_authenticates() throws Exception { + AuthResult authResult = serviceAccountAuthenticationMechanism.authenticate(request); + assertThat(authResult.isAuthenticated()).isTrue(); + assertThat(authResult.authLevel()).isEqualTo(AuthLevel.APP); + } + + @Test + void testFails_authenticateWrongEmail() throws Exception { + token.getPayload().set("email", "not-service-account-email@email.com"); + AuthResult authResult = serviceAccountAuthenticationMechanism.authenticate(request); + assertThat(authResult.isAuthenticated()).isFalse(); + } + + @Test + void testFails_authenticateWrongHeader() throws Exception { + when(request.getHeader(AUTHORIZATION)).thenReturn("BEARER asd"); + AuthResult authResult = serviceAccountAuthenticationMechanism.authenticate(request); + assertThat(authResult.isAuthenticated()).isFalse(); + } +} diff --git a/release/builder/Dockerfile b/release/builder/Dockerfile index 7f708956c..2fd7bb764 100644 --- a/release/builder/Dockerfile +++ b/release/builder/Dockerfile @@ -19,7 +19,16 @@ # 3. Google Cloud SDK for generating the WARs. # 4. Git to manipulate the private and the merged repos. # 5. Docker to build and push images. +# 6. cloudSchedulerDeployer for deploying cloud scheduler tasks based on config + +FROM golang:1.19 as cloudSchedulerBuilder +WORKDIR /usr/src/cloudSchedulerDeployer +RUN go mod init cloudSchedulerDeployer +COPY *.go ./ +RUN go build -o /cloudSchedulerDeployer + FROM marketplace.gcr.io/google/debian10 ENV DEBIAN_FRONTEND=noninteractive LANG=en_US.UTF-8 +COPY --from=cloudSchedulerBuilder /cloudSchedulerDeployer /usr/local/bin/cloudSchedulerDeployer ADD ./build.sh . RUN ["bash", "./build.sh"] diff --git a/release/builder/cloudSchedulerDeployer.go b/release/builder/cloudSchedulerDeployer.go new file mode 100644 index 000000000..ef229d6f5 --- /dev/null +++ b/release/builder/cloudSchedulerDeployer.go @@ -0,0 +1,176 @@ +// Copyright 2023 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The cloudScheduler tool allows creating, updating and deleting cloud +// scheduler jobs from xml config file + +package main + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" +) + +func main() { + if len(os.Args) < 3 || os.Args[1] == "" || os.Args[2] == "" { + panic("Error - invalid parameters:\nFirst parameter required - config file path;\nSecond parameter required - project name") + } + + // Config file path + configFileLocation := os.Args[1] + // Project name where to submit the tasks + projectName := os.Args[2] + + log.Default().Println("Filepath " + configFileLocation) + + xmlFile, err := os.Open(configFileLocation) + if err != nil { + panic(err) + } + defer xmlFile.Close() + + type Task struct { + XMLName xml.Name `xml:"task"` + URL string `xml:"url"` + Description string `xml:"description"` + Schedule string `xml:"schedule"` + Name string `xml:"name"` + } + + type Taskentries struct { + XMLName xml.Name `xml:"taskentries"` + Task []Task `xml:"task"` + } + + type ServiceAccount struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + } + + type ExistingJob struct { + Name string `json:"name"` + State string `json:"state"` + } + + byteValue, _ := io.ReadAll(xmlFile) + + var taskEntries Taskentries + + if err := xml.Unmarshal(byteValue, &taskEntries); err != nil { + panic("Failed to unmarshal taskentries: " + err.Error()) + } + + getArgs := func(taskRecord Task, operationType string, serviceAccountEmail string) []string { + // Cloud Schedule doesn't allow description of more than 499 chars and \n + var description string + if len(taskRecord.Description) > 499 { + description = taskRecord.Description[:499] + } else { + description = taskRecord.Description + } + description = strings.ReplaceAll(description, "\n", " ") + + return []string{ + "--project", projectName, + "scheduler", "jobs", operationType, + "http", taskRecord.Name, + "--location", "us-central1", + "--schedule", taskRecord.Schedule, + "--uri", fmt.Sprintf("https://backend-dot-%s.appspot.com%s", projectName, taskRecord.URL), + "--description", description, + "--http-method", "get", + "--oidc-service-account-email", serviceAccountEmail, + "--oidc-token-audience", projectName, + } + } + + // Get existing jobs from Cloud Scheduler + var allExistingJobs []ExistingJob + cmdGetExistingList := exec.Command("gcloud", "scheduler", "jobs", "list", "--project="+projectName, "--location=us-central1", "--format=json") + cmdGetExistingListOutput, cmdGetExistingListError := cmdGetExistingList.CombinedOutput() + if cmdGetExistingListError != nil { + panic("Can't obtain existing cloud scheduler jobs for " + projectName) + } + err = json.Unmarshal(cmdGetExistingListOutput, &allExistingJobs) + if err != nil { + panic("Failed to parse existing jobs from cloud schedule: " + err.Error()) + } + + // Sync deleted jobs + enabledExistingJobs := map[string]bool{} + for i := 0; i < len(allExistingJobs); i++ { + jobName := strings.Split(allExistingJobs[i].Name, "jobs/")[1] + toBeDeleted := true + if allExistingJobs[i].State == "ENABLED" { + enabledExistingJobs[jobName] = true + } + for i := 0; i < len(taskEntries.Task); i++ { + if taskEntries.Task[i].Name == jobName { + toBeDeleted = false + break + } + } + if toBeDeleted { + cmdDelete := exec.Command("gcloud", "scheduler", "jobs", "delete", jobName, "--project="+projectName, "--quiet") + cmdDeleteOutput, cmdDeleteError := cmdDelete.CombinedOutput() + log.Default().Println("Deleting cloud scheduler job " + jobName) + if cmdDeleteError != nil { + panic(string(cmdDeleteOutput)) + } + } + } + + // Find service account email + var serviceAccounts []ServiceAccount + var serviceAccountEmail string + cmdGetServiceAccounts := exec.Command("gcloud", "iam", "service-accounts", "list", "--project="+projectName, "--format=json") + cmdGetServiceAccountsOutput, cmdGetServiceAccountsError := cmdGetServiceAccounts.CombinedOutput() + if cmdGetServiceAccountsError != nil { + panic(cmdGetServiceAccountsError) + } + err = json.Unmarshal(cmdGetServiceAccountsOutput, &serviceAccounts) + if err != nil { + panic(err.Error()) + } + for i := 0; i < len(serviceAccounts); i++ { + if serviceAccounts[i].DisplayName == "cloud-scheduler" { + serviceAccountEmail = serviceAccounts[i].Email + break + } + } + if serviceAccountEmail == "" { + panic("Service account for cloud scheduler is not created for " + projectName) + } + + // Sync created and updated jobs + for i := 0; i < len(taskEntries.Task); i++ { + cmdType := "update" + if enabledExistingJobs[taskEntries.Task[i].Name] != true { + cmdType = "create" + } + + syncCommand := exec.Command("gcloud", getArgs(taskEntries.Task[i], cmdType, serviceAccountEmail)...) + syncCommandOutput, syncCommandError := syncCommand.CombinedOutput() + log.Default().Println(cmdType + " cloud scheduler job " + taskEntries.Task[i].Name) + if syncCommandError != nil { + panic(string(syncCommandOutput)) + } + } +} diff --git a/release/cloudbuild-deploy.yaml b/release/cloudbuild-deploy.yaml index 7f989565a..2f84aa878 100644 --- a/release/cloudbuild-deploy.yaml +++ b/release/cloudbuild-deploy.yaml @@ -28,6 +28,22 @@ steps: set -e gcloud secrets versions access latest \ --secret nomulus-tool-cloudbuild-credential > tool-credential.json +# Create/Update cloud scheduler jobs based on a cloud-scheduler-tasks.xml +- name: 'gcr.io/$PROJECT_ID/builder:latest' + entrypoint: /bin/bash + args: + - -c + - | + set -e + gcloud auth activate-service-account --key-file=tool-credential.json + if [ ${_ENV} == production ]; then + project_id="domain-registry" + else + project_id="domain-registry-${_ENV}" + fi + gsutil cp gs://$PROJECT_ID-deploy/${TAG_NAME}/${_ENV}.tar . + tar -xvf ${_ENV}.tar + cloudSchedulerDeployer default/WEB-INF/cloud-scheduler-tasks.xml $project_id # Deploy the GAE config files. # First authorize the gcloud tool to use the credential json file, then # download and unzip the tarball that contains the relevant config files @@ -43,8 +59,6 @@ steps: else project_id="domain-registry-${_ENV}" fi - gsutil cp gs://$PROJECT_ID-deploy/${TAG_NAME}/${_ENV}.tar . - tar -xvf ${_ENV}.tar for filename in cron dispatch queue; do gcloud -q --project $project_id app deploy \ default/WEB-INF/appengine-generated/$filename.yaml