Repository: google/exposure-notifications-server
Pull request #362 (2020-05-19 05:18:51)

Terraform deployment edits
Cloud SQL high traffic info commented out - further context needed

google-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/362#" title="Author self-approved">llatif</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[docs/OWNERS](https://github.com/google/exposure-notifications-server/blob/master/docs/OWNERS)~~ [llatif]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
crwilcox: Please remove line 167 to around 182. That was some notation I had in while researching a reasonably small setup for this if folks wanted to scale down. None of it is really relevant to the deployment docs
google-prow-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/362#pullrequestreview-414113117" title="Approved">crwilcox</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/362#" title="Author self-approved">llatif</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[docs/OWNERS](https://github.com/google/exposure-notifications-server/blob/master/docs/OWNERS)~~ [crwilcox,llatif]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-prow-robot: New changes are detected. LGTM label has been removed.

Patch:
diff --git a/builders/build.yaml b/builders/build.yaml
index b3c353b..73568b2 100644
--- a/builders/build.yaml
+++ b/builders/build.yaml
@@ -2,45 +2,72 @@ options:
   env:
   - 'KO_DOCKER_REPO=us.gcr.io/${PROJECT_ID}'
   - 'DOCKER_REPO_OVERRIDE=us.gcr.io/${PROJECT_ID}'
+  machineType: N1_HIGHCPU_8
+
 steps:
 # Build ko, prerequisite.
-- name: 'gcr.io/cloud-builders/docker'
+- id: ko-build
+  name: 'gcr.io/cloud-builders/docker'
   args: ['build', '--tag=gcr.io/$PROJECT_ID/ko', '-f', 'terraform/ko.Dockerfile', 'terraform/']
-- name: 'gcr.io/cloud-builders/docker'
+  waitFor: ['-']
+- id: ko-push
+  name: 'gcr.io/cloud-builders/docker'
   args: ['push', 'gcr.io/$PROJECT_ID/ko']
+  waitFor: ['ko-build']
+
 # Tests
-- name: 'mirror.gcr.io/library/golang'
+- id: test
+  name: 'mirror.gcr.io/library/golang'
   env:
   - GO111MODULE=on
   args: ['go', 'test', './...']
+  waitFor: ['-']
+
 # Build and publish containers`
-- name: 'gcr.io/$PROJECT_ID/ko'
+- id: export
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/export
-- name: 'gcr.io/$PROJECT_ID/ko'
+  waitFor: ['ko-push', 'test']
+
+- id: federationin
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/federationin
-- name: 'gcr.io/$PROJECT_ID/ko'
+  waitFor: ['ko-push', 'test']
+
+- id: federationout
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/federationout
-- name: 'gcr.io/$PROJECT_ID/ko'
+  waitFor: ['ko-push', 'test']
+
+- id: exposure
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/exposure
-- name: 'gcr.io/$PROJECT_ID/ko'
+  waitFor: ['ko-push', 'test']
+
+- id: cleanup-export
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/cleanup-export
-- name: 'gcr.io/$PROJECT_ID/ko'
+  waitFor: ['ko-push', 'test']
+
+- id: cleanup-exposure
+  name: 'gcr.io/$PROJECT_ID/ko'
   args:
   - publish
   - -P
   - ./cmd/cleanup-exposure
+  waitFor: ['ko-push', 'test']
diff --git a/builders/deploy.yaml b/builders/deploy.yaml
index 06819d0..bd455cc 100644
--- a/builders/deploy.yaml
+++ b/builders/deploy.yaml
@@ -50,35 +50,31 @@ steps:
   entrypoint: 'bash'
   args:
   - '-c'
-  # `--platform managed` and `--no-traffic` only works in `alpha`
   - |
-    gcloud alpha run deploy exposure --image \
+    gcloud run deploy exposure --image \
       us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/exposure:latest --region us-central1 \
       --platform managed --update-labels=env=staging --no-traffic
 - name: 'gcr.io/cloud-builders/gcloud'
   entrypoint: 'bash'
   args:
   - '-c'
-  # `--platform managed` and `--no-traffic` only works in `alpha`
   - |
-    gcloud alpha run deploy export --image \
+    gcloud run deploy export --image \
       us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/export:latest --region us-central1 \
       --platform managed --update-labels=env=staging --no-traffic
 - name: 'gcr.io/cloud-builders/gcloud'
   entrypoint: 'bash'
   args:
   - '-c'
-  # `--platform managed` and `--no-traffic` only works in `alpha`
   - |
-    gcloud alpha run deploy federationin --image \
+    gcloud run deploy federationin --image \
       us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/federationin:latest --region us-central1 \
       --platform managed --update-labels=env=staging --no-traffic
 - name: 'gcr.io/cloud-builders/gcloud'
   entrypoint: 'bash'
   args:
   - '-c'
-  # `--platform managed` and `--no-traffic` only works in `alpha`
   - |
-    gcloud alpha run deploy federationout --image \
+    gcloud run deploy federationout --image \
       us.gcr.io/$PROJECT_ID/github.com/google/exposure-notifications-server/cmd/federationout:latest --region us-central1 \
       --platform managed --update-labels=env=staging --no-traffic
diff --git a/docs/deploying.md b/docs/deploying.md
index fd59ed9..cc5417e 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -22,14 +22,14 @@ To build and deploy a service, you need to install and configure the following:
 
 1. Enable Go modules and install the `ko` container builder and deployment tool:
 
-    ```
+    ```console
     GO111MODULE=on
     go get github.com/google/ko/cmd/ko
     ```
 
 1. Configure the `ko` tool using the `setup_ko.sh` configuration file:
 
-    ```
+    ```console
     source setup_ko.sh
     ```
 
@@ -40,7 +40,7 @@ To build and deploy a service:
 1. Generate a [Google Cloud Repository](https://cloud.google.com/container-registry)
    Docker configuration:
 
-    ```
+    ```console
     gcloud auth configure-docker
     ```
 
@@ -49,7 +49,7 @@ To build and deploy a service:
 
     For example, to deploy the exposure key server:
 
-    ```
+    ```console
     ko publish ./cmd/exposure
     ```
 
@@ -69,119 +69,110 @@ Each service's `main` package is located in the `cmd` directory.
 | exposure cleanup | cmd/cleanup-exposure | Deletes old exposure keys |
 | export cleanup | cmd/cleanup-export | Deletes old exported files published by the exposure key export service |
 
-### Deploying Using Terraform configs
-
-The included terraform configs should enable you to bring up a copy of
-the exposure notifications server on GCP.  It makes heavy use of the GCP
-terraform provider, developed at
-https://github.com/terraform-providers/terraform-provider-google.
-
-1. Download and install Terraform 0.12.  [Installation guide](https://www.terraform.io/downloads.html),
-although `go get github.com/hashicorp/terraform` may be all you need.
-
-1.  Create a GCP project.
-    [Instructions](https://cloud.google.com/resource-manager/docs/creating-managing-projects).
-    Enable a billing account for this project, and remember its project ID (the
-    unique, unchangeable string that you will be asked for during creation).
-    
-    ```text
-    $ export PROJECT_ID="<value-from-above>"
-     ```
-
-1.  (OPTIONAL) Decide whether or not to use cloud build triggers. If you do, 
-    every push to master on the GitHub repo containing the exposure server code
-    will trigger a new deployment. To enable this:
-
-    1. Visit https://console.cloud.google.com/cloud-build/triggers/connect and follow the instructions to connect as a Cloud Build GitHub App. You must choose a repository that you have admin permissions on.
-
-    1. Remember which repo you used. You will need to set the repo owner (e.g. 'google') and name (e.g. 'exposure-notifications-server') as variables in the `terraform apply`
-
-1.  Authenticate to gcloud with:
+### Deploying using Terraform
 
-    ```text
-    $ gcloud auth login && gcloud auth application-default login
-    ```
-
-    This will open two authentication windows in your web browser.
-
-    >  **NOTE** You may need to `unset GOOGLE_APPLICATION_CREDENTIALS` as it
-    >  takes precedence over the gcloud login settings.
+You can use Terraform to deploy the reference Exposure Notification on Google
+Cloud. These instructions make use of the
+[Google Cloud Terraform provider](https://github.com/terraform-providers/terraform-provider-google).
 
-1.  Change to this directory and run `terraform init`.  Terraform will
-    automatically download the plugins required to execute this code.
+1. [Download and install Terraform version 0.12](https://www.terraform.io/downloads.html)
+   or newer.
 
-1.  Execute Terraform:
+1. [Create a Google Cloud project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project)
 
-    Without Cloud Build Triggers:
+   Make a note of the project ID, you will be needed later. We recommend
+   setting it as an environment variable:
 
-    ```text
-    $ terraform apply \
-        -var project=$PROJECT_ID
-    ```
+   ```console
+   export PROJECT_ID="PROJECT-ID"
+   ```
 
-    With Cloud Build Triggers:
+1. **(OPTIONAL)** You can use [Cloud Build triggers](https://cloud.google.com/cloud-build/docs/automating-builds/create-github-app-triggers)
+   to automatically re-deploy when the Exposure Notification Server GitHub
+   repository is updated. To set up Cloud Build triggers:
 
-    ```text
-    $ terraform apply \
-        -var project=${PROJECT_ID} \
-        -var region="us-central-1" \
-        -var use_build_triggers=true \
-        -var repo_owner=${YOUR_REPO_OWNER} \
-        -var repo_name=${YOUR_REPO_NAME}
-    ```
+   1. Go to the
+   [Connect Repository Cloud Console page](https://console.cloud.google.com/cloud-build/triggers/connect)
+   and follow the instructions using GitHub as the source code location
+   (Cloud Build GitHub App). You must choose a repository on which you have
+   administrator permissions.
 
-Terraform will begin by creating the service accounts and enabling the services
-on GCP which are required to run this server.
+   1. Make a note of the repository you have chosen. You must set the
+   repository name (for example, 'exposure-notification-server') and owner
+   (for example 'google') as variables when running the `terraform apply`
+   command.
 
-> NOTE: This configuration assumes production scale. The scale of this means
-> a substantial billed amount. You can downsize this to save on costs
-> For example you can set other vars in terraform apply to smaller values.
-> ```
->  $ terraform apply \
->       -var project=${PROJECT_ID} \
->       -var region="us-central-1" \
->       -var use_build_triggers=true \
->       -var repo_owner=${YOUR_REPO_OWNER} \
->       -var repo_name=${YOUR_REPO_NAME} \
->       -var cloudsql_tier="db-custom-1-3840" \
->       -var cloudsql_disk_size="16"
-> ```
+1. Authenticate with Google Cloud using the `gcloud` command-line tool:
 
+   ```console
+   gcloud auth login && gcloud auth application-default login
+   ```
 
-electin vCPU and Postgres size (concurrent connections):
+   This will open two authentication windows in your web browser.
 
-PostgresSql sizing
-And pricing
-https://cloud.google.com/sql/docs/postgres/create-instance
-Combined with connection limits: https://cloud.google.com/sql/docs/quotas#cloud-sql-for-postgresql-connection-limits
-Choice for now:
+   You may need to `unset GOOGLE_APPLICATION_CREDENTIALS` as it takes precedence
+   over gcloud's login settings.
 
-db-custom-8-30720
+1. Change to this directory and run `terraform init`.  Terraform will
+   automatically download the plugins required to execute this code.
 
+1. Run `terraform apply` to start deployment:
 
-30 gb which gives 500 concurrent connections
+   Without Cloud Build Triggers:
 
+   ```console
+   terraform apply \
+     -var project=$PROJECT_ID
+   ```
 
+   With Cloud Build Triggers:
 
+   ```console
+   terraform apply \
+     -var project=${PROJECT_ID} \
+     -var region="us-central-1" \
+     -var use_build_triggers=true \
+     -var repo_owner=${YOUR_REPO_OWNER} \
+     -var repo_name=${YOUR_REPO_NAME}
+   ```
 
-1.  Initialize and/or Migrate the DB.
-
-    > **NOTE** In the future this may be handled by terraform
-    
-    To migrate the database, you will want to start the
-    [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/quickstart-proxy-test#install-proxy)
-    and then run the [migrate](https://github.com/golang-migrate/migrate)
-    command.
-    
-    ```text
-    $ DB_HOST="localhost"
-    $ DB_PORT="1433"
-    $ DB_USER="notification"
-    $ DB_PASSWORD="YOUR-DB-PASSWORD"
-    $ DB_SSLMODE="disable"
-    $ DB_NAME="main"
-    $ DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
-
-    $ migrate -database ${DB_URL} -path ./migrations up
-    ```
-
+Terraform will begin by creating the service accounts and enabling the services
+on Google Cloud that are required to run this server.
+
+1. Initialize or migrate the database.
+
+   To migrate the database, you will want to start the
+   [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/quickstart-proxy-test#install-proxy)
+   and then run the [`migrate`](https://github.com/golang-migrate/migrate)
+   command.
+
+   ```console
+   DB_HOST="localhost"
+   DB_PORT="1433"
+   DB_USER="notification"
+   DB_PASSWORD="YOUR-DB-PASSWORD"
+   DB_SSLMODE="disable"
+   DB_NAME="main"
+   DB_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
+
+   migrate -database ${DB_URL} -path ./migrations up
+   ```
+
+### Local development and testing example deployment
+
+The default Terraform deployment is a production-ready, high traffic
+deployment. For local development and testing, we recommend you use the
+following sample deployment:
+
+1. Run `terraform apply` with the following command:
+
+   ```console
+   terraform apply \
+     -var project=${PROJECT_ID} \
+     -var region="us-central-1" \
+     -var use_build_triggers=true \
+     -var repo_owner=${YOUR_REPO_OWNER} \
+     -var repo_name=${YOUR_REPO_NAME} \
+     -var cloudsql_tier="db-custom-1-3840" \
+     -var cloudsql_disk_size_gb="16"
+   ```
diff --git a/internal/android/safetynet.go b/internal/android/safetynet.go
index a0cd834..ee963c4 100644
--- a/internal/android/safetynet.go
+++ b/internal/android/safetynet.go
@@ -140,23 +140,23 @@ func ValidateAttestation(ctx context.Context, attestation string, opts *VerifyOp
 // VerifyOptsFor returns the Android SafetyNet verification options to be used
 // based on the AuthorizedApp configuration, request time, and nonce.
 func VerifyOptsFor(c *database.AuthorizedApp, from time.Time, nonce string) *VerifyOpts {
-	digests := make([]string, len(c.ApkDigestSHA256))
-	copy(digests, c.ApkDigestSHA256)
+	digests := make([]string, len(c.SafetyNetApkDigestSHA256))
+	copy(digests, c.SafetyNetApkDigestSHA256)
 	rtn := &VerifyOpts{
 		AppPkgName:      c.AppPackageName,
-		CTSProfileMatch: c.CTSProfileMatch,
-		BasicIntegrity:  c.BasicIntegrity,
+		BasicIntegrity:  c.SafetyNetBasicIntegrity,
+		CTSProfileMatch: c.SafetyNetCTSProfileMatch,
 		APKDigest:       digests,
 		Nonce:           nonce,
 	}
 
 	// Calculate the valid time window based on now + config options.
-	if c.AllowedPastTime > 0 {
-		minTime := from.Add(-c.AllowedPastTime)
+	if c.SafetyNetPastTime > 0 {
+		minTime := from.Add(-c.SafetyNetPastTime)
 		rtn.MinValidTime = minTime
 	}
-	if c.AllowedFutureTime > 0 {
-		maxTime := from.Add(c.AllowedFutureTime)
+	if c.SafetyNetFutureTime > 0 {
+		maxTime := from.Add(c.SafetyNetFutureTime)
 		rtn.MaxValidTime = maxTime
 	}
 
diff --git a/internal/database/authorized_app.go b/internal/database/authorized_app.go
index ff1b54f..efc1073 100644
--- a/internal/database/authorized_app.go
+++ b/internal/database/authorized_app.go
@@ -36,9 +36,9 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 
 	query := `
 		SELECT
-			app_package_name, platform, apk_digest, cts_profile_match, basic_integrity,
-			allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions,
-			ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
+			app_package_name, platform, allowed_regions,
+			safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+			devicecheck_team_id_secret, devicecheck_key_id_secret, devicecheck_private_key_secret
 		FROM
 			AuthorizedApp
 		WHERE app_package_name = $1`
@@ -47,11 +47,11 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 
 	config := NewAuthorizedApp()
 	var allowedRegions []string
-	var allowedPastSeconds, allowedFutureSeconds *int
+	var safetyNetPastSeconds, safetyNetFutureSeconds *int
 	var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
 	if err := row.Scan(
-		&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256, &config.CTSProfileMatch, &config.BasicIntegrity,
-		&allowedPastSeconds, &allowedFutureSeconds, &allowedRegions, &config.AllowAllRegions,
+		&config.AppPackageName, &config.Platform, &allowedRegions,
+		&config.SafetyNetApkDigestSHA256, &config.SafetyNetCTSProfileMatch, &config.SafetyNetBasicIntegrity, &safetyNetPastSeconds, &safetyNetFutureSeconds,
 		&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret,
 	); err != nil {
 		if err == pgx.ErrNoRows {
@@ -61,13 +61,13 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 	}
 
 	// Convert time in seconds from DB into time.Duration
-	if allowedPastSeconds != nil {
-		d := time.Duration(*allowedPastSeconds) * time.Second
-		config.AllowedPastTime = d
+	if safetyNetPastSeconds != nil {
+		d := time.Duration(*safetyNetPastSeconds) * time.Second
+		config.SafetyNetPastTime = d
 	}
-	if allowedFutureSeconds != nil {
-		d := time.Duration(*allowedFutureSeconds) * time.Second
-		config.AllowedFutureTime = d
+	if safetyNetFutureSeconds != nil {
+		d := time.Duration(*safetyNetFutureSeconds) * time.Second
+		config.SafetyNetFutureTime = d
 	}
 
 	// build the regions map
@@ -79,7 +79,7 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 	if v := deviceCheckTeamIDSecret; v.Valid && v.String != "" {
 		plaintext, err := sm.GetSecretValue(ctx, v.String)
 		if err != nil {
-			return nil, fmt.Errorf("ios_devicecheck_team_id_secret at %s (%s): %w",
+			return nil, fmt.Errorf("devicecheck_team_id_secret at %s (%s): %w",
 				config.AppPackageName, config.Platform, err)
 		}
 		config.DeviceCheckTeamID = plaintext
@@ -88,7 +88,7 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 	if v := deviceCheckKeyIDSecret; v.Valid && v.String != "" {
 		plaintext, err := sm.GetSecretValue(ctx, v.String)
 		if err != nil {
-			return nil, fmt.Errorf("ios_devicecheck_key_id_secret at %s (%s): %w",
+			return nil, fmt.Errorf("devicecheck_key_id_secret at %s (%s): %w",
 				config.AppPackageName, config.Platform, err)
 		}
 		config.DeviceCheckKeyID = plaintext
@@ -97,7 +97,7 @@ func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, na
 	if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
 		plaintext, err := sm.GetSecretValue(ctx, v.String)
 		if err != nil {
-			return nil, fmt.Errorf("ios_devicecheck_private_key_secret at %s (%s): %w",
+			return nil, fmt.Errorf("devicecheck_private_key_secret at %s (%s): %w",
 				config.AppPackageName, config.Platform, err)
 		}
 
diff --git a/internal/database/authorized_app_model.go b/internal/database/authorized_app_model.go
index 5d5fed2..5bbb654 100644
--- a/internal/database/authorized_app_model.go
+++ b/internal/database/authorized_app_model.go
@@ -28,18 +28,22 @@ const (
 // application and their access to and requirements for using the API. DB times
 // of 0 are interpreted to be "unbounded" in that direction.
 type AuthorizedApp struct {
-	AppPackageName  string
-	Platform        string
-	AllowedRegions  map[string]struct{}
-	AllowAllRegions bool
+	// AppPackageName is the name of the package like com.company.app.
+	AppPackageName string
+
+	// Platform is the app platform like "android" or "ios".
+	Platform string
+
+	// AllowedRegions is the list of allowed regions for this app. If the list is
+	// empty, all regions are permitted.
+	AllowedRegions map[string]struct{}
 
 	// SafetyNet configuration.
-	// TODO(sethvargo): Rename these to clarify they are for SafetyNet.
-	AllowedPastTime   time.Duration
-	AllowedFutureTime time.Duration
-	ApkDigestSHA256   []string
-	BasicIntegrity    bool
-	CTSProfileMatch   bool
+	SafetyNetApkDigestSHA256 []string
+	SafetyNetBasicIntegrity  bool
+	SafetyNetCTSProfileMatch bool
+	SafetyNetPastTime        time.Duration
+	SafetyNetFutureTime      time.Duration
 
 	// DeviceCheck configuration.
 	DeviceCheckKeyID      string
@@ -63,9 +67,13 @@ func (c *AuthorizedApp) IsAndroid() bool {
 	return c.Platform == androidDevice
 }
 
-// IsAllowedRegion returns true if the region is in the list of allowed regions,
-// false otherwise.
+// IsAllowedRegion returns true if the regions list is empty or if the given
+// region is in the list of allowed regions.
 func (c *AuthorizedApp) IsAllowedRegion(s string) bool {
+	if len(c.AllowedRegions) == 0 {
+		return true
+	}
+
 	_, ok := c.AllowedRegions[s]
 	return ok
 }
diff --git a/internal/database/export.go b/internal/database/export.go
index d273681..aa73739 100644
--- a/internal/database/export.go
+++ b/internal/database/export.go
@@ -507,7 +507,7 @@ func (db *DB) DeleteFilesBefore(ctx context.Context, before time.Time, blobstore
 			SELECT
 				eb.batch_id,
 				eb.status,
-				eb.bucket_name
+				eb.bucket_name,
 				ef.filename,
 				ef.batch_size,
 				ef.status
diff --git a/internal/publish/config.go b/internal/publish/config.go
index 09b873c..9bc048f 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -39,6 +39,7 @@ type Config struct {
 	// Bypass flags for local development and testing.
 	BypassSafetyNet   bool `envconfig:"BYPASS_SAFETYNET"`
 	BypassDeviceCheck bool `envconfig:"BYPASS_DEVICECHECK"`
+	DebugAPIResponses bool `envconfig:"DEBUG_API_RESPONSES"`
 
 	AuthorizedApp *authorizedapp.Config
 	Database      *database.Config
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 3ca219e..b31dd23 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -65,21 +65,25 @@ type publishHandler struct {
 	authorizedAppProvider authorizedapp.Provider
 }
 
-// There is a target normalized latency for this function. This is to help prevent
-// clients from being able to distinguish from successful or errored requests.
-func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+type response struct {
+	status      int
+	message     string
+	metric      string
+	count       int // metricCount
+	errorInProd bool
+}
+
+func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) response {
 	ctx := r.Context()
 	logger := logging.FromContext(ctx)
-	metrics := h.serverenv.MetricsExporter(ctx)
 
 	var data *database.Publish
 	code, err := jsonutil.Unmarshal(w, r, &data)
 	if err != nil {
 		// Log the unparsable JSON, but return success to the client.
-		logger.Errorf("error unmarshalling API call, code: %v: %v", code, err)
-		metrics.WriteInt("publish-bad-json", true, 1)
-		w.WriteHeader(http.StatusOK)
-		return
+		message := fmt.Sprintf("error unmarshalling API call, code: %v: %v", code, err)
+		logger.Error(message)
+		return response{status: http.StatusBadRequest, message: message, metric: "publish-bad-json", count: 1}
 	}
 
 	appConfig, err := h.authorizedAppProvider.AppConfig(ctx, data.AppPackageName)
@@ -88,83 +92,106 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		// happen if the app was recently registered but the cache hasn't been
 		// refreshed.
 		if err == authorizedapp.AppNotFound {
-			logger.Errorf("unauthorized app: %v", data.AppPackageName)
-			metrics.WriteInt("publish-app-not-authorized", true, 1)
-			// This returns success to the client.
-			w.WriteHeader(http.StatusOK)
-			return
+			message := fmt.Sprintf("unauthorized app: %v", data.AppPackageName)
+			logger.Error(message)
+			return response{status: http.StatusUnauthorized, message: message, metric: "publish-app-not-authorized", count: 1}
 		}
 
 		// A higher-level configuration error occurred, likely while trying to read
 		// from the database. This is retryable, although won't succeed if the error
 		// isn't transient.
 		logger.Errorf("no AuthorizedApp, dropping data: %v", err)
-		metrics.WriteInt("publish-error-loading-authorizedapp", true, 1)
-		http.Error(w, http.StatusText(http.StatusInternalServerError),
-			http.StatusInternalServerError)
-		return
+		return response{
+			status:      http.StatusInternalServerError,
+			message:     http.StatusText(http.StatusInternalServerError),
+			metric:      "publish-error-loading-authorizedapp",
+			count:       1,
+			errorInProd: true,
+		}
 	}
 
 	if err := verification.VerifyRegions(appConfig, data); err != nil {
-		logger.Errorf("verification.VerifyRegions: %v", err)
-		metrics.WriteInt("publish-region-not-authorized", true, 1)
-		// This returns success to the client.
-		w.WriteHeader(http.StatusOK)
-		return
+		message := fmt.Sprintf("verifying allowed regions: %v", err)
+		return response{status: http.StatusUnauthorized, message: message, metric: "publish-region-not-authorized", count: 1}
 	}
 
 	if appConfig.IsIOS() {
 		if h.config.BypassDeviceCheck {
 			logger.Errorf("bypassing DeviceCheck for %v", data.AppPackageName)
-			metrics.WriteInt("publish-devicecheck-bypass", true, 1)
+			h.serverenv.MetricsExporter(ctx).WriteInt("publish-devicecheck-bypass", true, 1)
 		} else if err := verification.VerifyDeviceCheck(ctx, appConfig, data); err != nil {
-			logger.Errorf("unable to verify devicecheck payload: %v", err)
-			metrics.WriteInt("publish-devicecheck-invalid", true, 1)
-			// Return success to the client.
-			w.WriteHeader(http.StatusOK)
-			return
+			message := fmt.Sprintf("unable to verify devicecheck payload: %v", err)
+			logger.Error(message)
+			return response{status: http.StatusUnauthorized, message: message, metric: "publish-devicecheck-invalid", count: 1}
 		}
 	} else if appConfig.IsAndroid() {
 		if h.config.BypassSafetyNet {
 			logger.Errorf("bypassing SafetyNet for %v", data.AppPackageName)
-			metrics.WriteInt("publish-safetynet-bypass", true, 1)
+			h.serverenv.MetricsExporter(ctx).WriteInt("publish-safetynet-bypass", true, 1)
 		} else if err := verification.VerifySafetyNet(ctx, time.Now(), appConfig, data); err != nil {
-			logger.Errorf("unable to verify safetynet payload: %v", err)
-			metrics.WriteInt("publish-safetnet-invalid", true, 1)
-			// Return success to the client.
-			w.WriteHeader(http.StatusOK)
-			return
+			message := fmt.Sprintf("unable to verify safetynet payload: %v", err)
+			logger.Error(message)
+			return response{status: http.StatusUnauthorized, message: message, metric: "publish-safetnet-invalid", count: 1}
 		}
 	} else {
-		logger.Errorf("invalid AuthorizedApp config %v: invalid platform %v",
-			data.AppPackageName, data.Platform)
-		metrics.WriteInt("publish-authorizedapp-missing-platform", true, 1)
-		// This returns success to the client.
-		w.WriteHeader(http.StatusOK)
-		return
+		message := fmt.Sprintf("invalid AuthorizedApp config %v: invalid platform %v", data.AppPackageName, data.Platform)
+		logger.Error(message)
+		return response{status: http.StatusInternalServerError, message: message, metric: "publish-authorizedapp-missing-platform", count: 1}
 	}
 
 	batchTime := time.Now()
 	exposures, err := h.transformer.TransformPublish(data, batchTime)
 	if err != nil {
-		logger.Errorf("error transforming publish data: %v", err)
-		metrics.WriteInt("publish-transform-fail", true, 1)
-		// This returns success to the client.
-		w.WriteHeader(http.StatusOK)
-		return
+		message := fmt.Sprintf("unable to read request data: %v", err)
+		logger.Error(message)
+		return response{status: http.StatusBadRequest, message: message, metric: "publish-transform-fail", count: 1}
 	}
 
 	err = h.database.InsertExposures(ctx, exposures)
 	if err != nil {
 		logger.Errorf("error writing exposure record: %v", err)
-		metrics.WriteInt("publish-db-write-error", true, 1)
-		// This is retryable at the client - database error at the server.
-		http.Error(w, http.StatusText(http.StatusInternalServerError),
-			http.StatusInternalServerError)
+		return response{status: http.StatusInternalServerError, message: http.StatusText(http.StatusInternalServerError), metric: "publish-db-write-error", count: 1}
+	}
+
+	message := fmt.Sprintf("Inserted %d exposures.", len(exposures))
+	logger.Info(message)
+	return response{
+		status:  http.StatusOK,
+		message: message,
+		metric:  "publish-exposures-written",
+		count:   len(exposures),
+	}
+}
+
+// There is a target normalized latency for this function. This is to help prevent
+// clients from being able to distinguish from successful or errored requests.
+func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	response := h.handleRequest(w, r)
+
+	if response.metric != "" {
+		ctx := r.Context()
+		metrics := h.serverenv.MetricsExporter(ctx)
+		metrics.WriteInt(response.metric, true, response.count)
+	}
+
+	// Handle success. If debug enabled, write the message in the response.
+	if response.status == http.StatusOK {
+		if h.config.DebugAPIResponses {
+			w.Write([]byte(response.message))
+		} else {
+			w.WriteHeader(http.StatusOK)
+		}
+		return
+	}
+
+	// If this error is written in non-debug times or if debug is enabled, write
+	// out the error and status.
+	if h.config.DebugAPIResponses || response.errorInProd {
+		w.WriteHeader(response.status)
+		w.Write([]byte(response.message))
 		return
 	}
-	metrics.WriteInt("publish-exposures-written", true, len(exposures))
-	logger.Infof("Inserted %d exposures.", len(exposures))
 
+	// Normal production behaviour. Success it up.
 	w.WriteHeader(http.StatusOK)
 }
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 554d478..80772b4 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -66,11 +66,11 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to secret manager: %v", err)
 	}
-	logger.Infof("Effective environment variables: %+v", config)
 
 	if err := envconfig.Process(ctx, config, sm); err != nil {
 		return nil, nil, fmt.Errorf("error loading environment variables: %v", err)
 	}
+	logger.Infof("Effective environment variables: %+v", config)
 
 	// Start building serverenv opts
 	opts := []serverenv.Option{
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index 3258830..a8c4434 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -33,16 +33,12 @@ var (
 // the configuration for the application.
 func VerifyRegions(cfg *database.AuthorizedApp, data *database.Publish) error {
 	if cfg == nil {
-		return fmt.Errorf("no allowed regions configured")
-	}
-
-	if cfg.AllowAllRegions {
-		return nil
+		return fmt.Errorf("app configuration is empty")
 	}
 
 	for _, r := range data.Regions {
 		if !cfg.IsAllowedRegion(r) {
-			return fmt.Errorf("application '%v' tried to write unauthorized region: '%v'", cfg.AppPackageName, r)
+			return fmt.Errorf("app '%v' tried to write unauthorized region: '%v'", cfg.AppPackageName, r)
 		}
 	}
 
diff --git a/migrations/000022_authorized_app_prefixes.down.sql b/migrations/000022_authorized_app_prefixes.down.sql
new file mode 100644
index 0000000..3af5c7d
--- /dev/null
+++ b/migrations/000022_authorized_app_prefixes.down.sql
@@ -0,0 +1,33 @@
+-- Copyright 2020 Google LLC
+--
+-- 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.
+
+BEGIN;
+
+ALTER TABLE AuthorizedApp RENAME COLUMN safetynet_apk_digest TO apk_digest;
+ALTER TABLE AuthorizedApp RENAME COLUMN safetynet_cts_profile_match TO cts_profile_match;
+ALTER TABLE AuthorizedApp RENAME COLUMN safetynet_basic_integrity TO basic_integrity;
+ALTER TABLE AuthorizedApp RENAME COLUMN safetynet_past_seconds TO allowed_past_seconds;
+ALTER TABLE AuthorizedApp RENAME COLUMN safetynet_future_seconds TO allowed_future_seconds;
+ALTER TABLE AuthorizedApp RENAME COLUMN devicecheck_team_id_secret TO ios_devicecheck_team_id_secret;
+ALTER TABLE AuthorizedApp RENAME COLUMN devicecheck_key_id_secret TO ios_devicecheck_key_id_secret;
+ALTER TABLE AuthorizedApp RENAME COLUMN devicecheck_private_key_secret TO ios_devicecheck_private_key_secret;
+
+-- Update the semantic meaning of all_regions
+ALTER TABLE AuthorizedApp ADD COLUMN all_regions bool DEFAULT false;
+UPDATE AuthorizedApp SET all_regions = true
+WHERE allowed_regions = '{}';
+
+CREATE INDEX authorized_app_app_package_name_idx ON AuthorizedApp(app_package_name);
+
+END;
diff --git a/migrations/000022_authorized_app_prefixes.up.sql b/migrations/000022_authorized_app_prefixes.up.sql
new file mode 100644
index 0000000..fff22e3
--- /dev/null
+++ b/migrations/000022_authorized_app_prefixes.up.sql
@@ -0,0 +1,32 @@
+-- Copyright 2020 Google LLC
+--
+-- 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.
+
+BEGIN;
+
+-- Update the semantic meaning of all_regions
+UPDATE AuthorizedApp SET allowed_regions = ARRAY[]::VARCHAR[]
+WHERE all_regions = true;
+
+ALTER TABLE AuthorizedApp RENAME COLUMN apk_digest TO safetynet_apk_digest;
+ALTER TABLE AuthorizedApp RENAME COLUMN cts_profile_match TO safetynet_cts_profile_match;
+ALTER TABLE AuthorizedApp RENAME COLUMN basic_integrity TO safetynet_basic_integrity;
+ALTER TABLE AuthorizedApp RENAME COLUMN allowed_past_seconds TO safetynet_past_seconds;
+ALTER TABLE AuthorizedApp RENAME COLUMN allowed_future_seconds TO safetynet_future_seconds;
+ALTER TABLE AuthorizedApp RENAME COLUMN ios_devicecheck_team_id_secret TO devicecheck_team_id_secret;
+ALTER TABLE AuthorizedApp RENAME COLUMN ios_devicecheck_key_id_secret TO devicecheck_key_id_secret;
+ALTER TABLE AuthorizedApp RENAME COLUMN ios_devicecheck_private_key_secret TO devicecheck_private_key_secret;
+ALTER TABLE AuthorizedApp DROP COLUMN all_regions;
+DROP INDEX authorized_app_app_package_name_idx;
+
+END;
diff --git a/terraform/ko.Dockerfile b/terraform/ko.Dockerfile
index d257125..e96c361 100644
--- a/terraform/ko.Dockerfile
+++ b/terraform/ko.Dockerfile
@@ -1,3 +1,17 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
 FROM golang
 RUN go get github.com/google/ko/cmd/ko
 ENTRYPOINT ["ko"]
diff --git a/terraform/main.tf b/terraform/main.tf
index 5a0a611..7146759 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -1,3 +1,17 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
 provider "google" {
   project = var.project
   region  = var.region
diff --git a/terraform/service_cleanup_export.tf b/terraform/service_cleanup_export.tf
new file mode 100644
index 0000000..8960c00
--- /dev/null
+++ b/terraform/service_cleanup_export.tf
@@ -0,0 +1,121 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+#
+# Create and deploy the service
+#
+
+resource "google_service_account" "cleanup-export" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-cleanup-export-sa"
+  display_name = "Exposure Notification Cleanup Export"
+}
+
+resource "google_project_iam_member" "cleanup-export-cloudsql" {
+  project = data.google_project.project.project_id
+  role    = "roles/cloudsql.client"
+  member  = "serviceAccount:${google_service_account.cleanup-export.email}"
+}
+
+resource "google_secret_manager_secret_iam_member" "cleanup-export-db-pwd" {
+  provider = google-beta
+
+  secret_id = google_secret_manager_secret.db-pwd.id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.cleanup-export.email}"
+}
+
+resource "google_storage_bucket_iam_member" "cleanup-export-objectadmin" {
+  bucket = google_storage_bucket.export.name
+  role   = "roles/storage.objectAdmin"
+  member = "serviceAccount:${google_service_account.cleanup-export.email}"
+}
+
+resource "google_cloud_run_service" "cleanup-export" {
+  name     = "cleanup-export"
+  location = var.region
+
+  template {
+    spec {
+      service_account_name = google_service_account.cleanup-export.email
+
+      containers {
+        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-export:latest"
+
+        dynamic "env" {
+          for_each = local.common_cloudrun_env_vars
+          content {
+            name  = env.value["name"]
+            value = env.value["value"]
+          }
+        }
+      }
+    }
+
+    metadata {
+      annotations = {
+        "run.googleapis.com/cloudsql-instances" : google_sql_database_instance.db-inst.connection_name
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    null_resource.submit-build-and-publish,
+  ]
+}
+
+
+#
+# Create scheduler job to invoke the service on a fixed interval.
+#
+
+resource "google_service_account" "cleanup-export-invoker" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-cleanup-export-invoker-sa"
+  display_name = "Exposure Notification Cleanup Export Invoker"
+}
+
+resource "google_cloud_run_service_iam_member" "cleanup-export-invoker" {
+  project  = google_cloud_run_service.cleanup-export.project
+  location = google_cloud_run_service.cleanup-export.location
+  service  = google_cloud_run_service.cleanup-export.name
+  role     = "roles/run.invoker"
+  member   = "serviceAccount:${google_service_account.scheduler-export.email}"
+}
+
+resource "google_cloud_scheduler_job" "cleanup-export-worker" {
+  name             = "cleanup-export-worker"
+  schedule         = "0 */6 * * *"
+  time_zone        = "Etc/UTC"
+  attempt_deadline = "600s"
+
+  retry_config {
+    retry_count = 3
+  }
+
+  http_target {
+    http_method = "POST"
+    uri         = "${google_cloud_run_service.cleanup-export.status.0.url}/"
+    oidc_token {
+      audience              = google_cloud_run_service.cleanup-export.status.0.url
+      service_account_email = google_service_account.scheduler-export.email
+    }
+  }
+
+  depends_on = [
+    google_app_engine_application.app,
+    google_cloud_run_service_iam_member.cleanup-export-invoker,
+  ]
+}
diff --git a/terraform/service_cleanup_exposure.tf b/terraform/service_cleanup_exposure.tf
new file mode 100644
index 0000000..98d328a
--- /dev/null
+++ b/terraform/service_cleanup_exposure.tf
@@ -0,0 +1,115 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
+#
+# Create and deploy the service
+#
+
+resource "google_service_account" "cleanup-exposure" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-cleanup-exposure-sa"
+  display_name = "Exposure Notification Cleanup Exposure"
+}
+
+resource "google_project_iam_member" "cleanup-exposure-cloudsql" {
+  project = data.google_project.project.project_id
+  role    = "roles/cloudsql.client"
+  member  = "serviceAccount:${google_service_account.cleanup-exposure.email}"
+}
+
+resource "google_secret_manager_secret_iam_member" "cleanup-exposure-db-pwd" {
+  provider = google-beta
+
+  secret_id = google_secret_manager_secret.db-pwd.id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.cleanup-exposure.email}"
+}
+
+resource "google_cloud_run_service" "cleanup-exposure" {
+  name     = "cleanup-exposure"
+  location = var.region
+
+  template {
+    spec {
+      service_account_name = google_service_account.cleanup-exposure.email
+
+      containers {
+        image = "us.gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/cleanup-exposure:latest"
+
+        dynamic "env" {
+          for_each = local.common_cloudrun_env_vars
+          content {
+            name  = env.value["name"]
+            value = env.value["value"]
+          }
+        }
+      }
+    }
+
+    metadata {
+      annotations = {
+        "run.googleapis.com/cloudsql-instances" : google_sql_database_instance.db-inst.connection_name
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    null_resource.submit-build-and-publish,
+  ]
+}
+
+
+#
+# Create scheduler job to invoke the service on a fixed interval.
+#
+
+resource "google_service_account" "cleanup-exposure-invoker" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-cleanup-exposure-invoker-sa"
+  display_name = "Exposure Notification Cleanup Exposure Invoker"
+}
+
+resource "google_cloud_run_service_iam_member" "cleanup-exposure-invoker" {
+  project  = google_cloud_run_service.cleanup-exposure.project
+  location = google_cloud_run_service.cleanup-exposure.location
+  service  = google_cloud_run_service.cleanup-exposure.name
+  role     = "roles/run.invoker"
+  member   = "serviceAccount:${google_service_account.scheduler-export.email}"
+}
+
+resource "google_cloud_scheduler_job" "cleanup-exposure-worker" {
+  name             = "cleanup-exposure-worker"
+  schedule         = "0 */4 * * *"
+  time_zone        = "Etc/UTC"
+  attempt_deadline = "600s"
+
+  retry_config {
+    retry_count = 3
+  }
+
+  http_target {
+    http_method = "POST"
+    uri         = "${google_cloud_run_service.cleanup-exposure.status.0.url}/"
+    oidc_token {
+      audience              = google_cloud_run_service.cleanup-exposure.status.0.url
+      service_account_email = google_service_account.scheduler-export.email
+    }
+  }
+
+  depends_on = [
+    google_app_engine_application.app,
+    google_cloud_run_service_iam_member.cleanup-exposure-invoker,
+  ]
+}
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 314d481..e033593 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -1,3 +1,17 @@
+# Copyright 2020 Google LLC
+#
+# 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.
+
 variable "region" {
   type    = string
   default = "us-central1"
@@ -29,14 +43,14 @@ variable "repo_name" {
 
 variable "cloudsql_tier" {
   type    = string
-  default = "db-custom-32-122880"
+  default = "db-custom-8-30720"
 
   description = "Size of the Cloud SQL tier. Set to db-custom-1-3840 or a smaller instance for local dev."
 }
 
 variable "cloudsql_disk_size_gb" {
   type    = number
-  default = 500
+  default = 256
 
   description = "Size of the Cloud SQL disk, in GB."
 }
diff --git a/tools/exposure-client/main.go b/tools/exposure-client/main.go
index a5b787d..8c0104c 100644
--- a/tools/exposure-client/main.go
+++ b/tools/exposure-client/main.go
@@ -21,6 +21,7 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"flag"
+	"io/ioutil"
 	"log"
 	"math/big"
 	"net/http"
@@ -98,15 +99,15 @@ func main() {
 		log.Fatalf("unable to marshal json payload")
 	}
 
-	sendRequest(jsonData)
-
 	prettyJSON, err := json.MarshalIndent(data, "", "  ")
 	if err != nil {
 		log.Printf("Can't display JSON that was sent, error: %v", err)
 	} else {
-		log.Printf("payload: \n%v", string(prettyJSON))
+		log.Printf("SENDING: \n%v", string(prettyJSON))
 	}
 
+	sendRequest(jsonData)
+
 	if *twice {
 		time.Sleep(1 * time.Second)
 		log.Printf("sending the request again...")
@@ -178,7 +179,15 @@ func sendRequest(jsonData []byte) {
 	}
 	defer resp.Body.Close()
 
-	log.Printf("response: %v", resp.Status)
+	log.Printf("status: %v", resp.Status)
+
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		log.Printf("unable to read http response: %v", err)
+	} else {
+		log.Printf("response: %v", string(body))
+	}
+
 	if resp.StatusCode != http.StatusOK {
 		log.Fatalf("Failure response from server.")
 	}


Test patch:
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index cec9651..f68419a 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -197,11 +197,11 @@ func TestVerifyOptsFor(t *testing.T) {
 	}{
 		{
 			cfg: &database.AuthorizedApp{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   true,
-				BasicIntegrity:    true,
-				AllowedPastTime:   time.Duration(15 * time.Minute),
-				AllowedFutureTime: time.Duration(1 * time.Second),
+				AppPackageName:           "foo",
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: true,
+				SafetyNetPastTime:        time.Duration(15 * time.Minute),
+				SafetyNetFutureTime:      time.Duration(1 * time.Second),
 			},
 			opts: &VerifyOpts{
 				AppPkgName:      "foo",
@@ -214,29 +214,29 @@ func TestVerifyOptsFor(t *testing.T) {
 		},
 		{
 			cfg: &database.AuthorizedApp{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
+				AppPackageName:           "foo",
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: false,
+				SafetyNetPastTime:        0,
+				SafetyNetFutureTime:      0,
 			},
 			opts: &VerifyOpts{
 				AppPkgName:      "foo",
 				APKDigest:       []string{},
-				CTSProfileMatch: false,
 				BasicIntegrity:  true,
+				CTSProfileMatch: false,
 				MinValidTime:    time.Time{},
 				MaxValidTime:    time.Time{},
 			},
 		},
 		{
 			cfg: &database.AuthorizedApp{
-				AppPackageName:    "foo",
-				ApkDigestSHA256:   []string{"bar"},
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
+				AppPackageName:           "foo",
+				SafetyNetApkDigestSHA256: []string{"bar"},
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: false,
+				SafetyNetPastTime:        0,
+				SafetyNetFutureTime:      0,
 			},
 			opts: &VerifyOpts{
 				AppPkgName:      "foo",
diff --git a/internal/database/authorized_app_test.go b/internal/database/authorized_app_test.go
index d6b9c23..7429628 100644
--- a/internal/database/authorized_app_test.go
+++ b/internal/database/authorized_app_test.go
@@ -83,69 +83,111 @@ func TestGetAuthorizedApp(t *testing.T) {
 				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
 				VALUES ($1, $2, $3)
 			`,
-			args: []interface{}{"myapp", "ios", []string{"US"}},
+
+			args: []interface{}{"myapp", "android", []string{"US"}},
+			exp: &AuthorizedApp{
+				AppPackageName:           "myapp",
+				Platform:                 "android",
+				AllowedRegions:           map[string]struct{}{"US": {}},
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: true,
+			},
+		},
+		{
+			name: "all_regions",
+			sql: `
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
+				VALUES ($1, $2, $3)
+			`,
+			args: []interface{}{"myapp", "android", []string{}},
 			exp: &AuthorizedApp{
-				AppPackageName:  "myapp",
-				Platform:        "ios",
-				AllowedRegions:  map[string]struct{}{"US": {}},
-				CTSProfileMatch: true,
-				BasicIntegrity:  true,
+				AppPackageName:           "myapp",
+				Platform:                 "android",
+				AllowedRegions:           map[string]struct{}{},
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: true,
 			},
 		},
 		{
-			name: "allowed_past_time",
+			name: "safetynet_fileds",
+			sql: `
+				INSERT INTO AuthorizedApp (
+					app_package_name, platform, allowed_regions,
+					safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity
+				)
+				VALUES (
+					$1, $2, $3,
+					$4, $5, $6
+				)
+			`,
+			args: []interface{}{
+				"myapp", "android", []string{},
+				[]string{"092fcfb", "252f10c"}, false, false,
+			},
+			exp: &AuthorizedApp{
+				AppPackageName:           "myapp",
+				Platform:                 "android",
+				AllowedRegions:           map[string]struct{}{},
+				SafetyNetApkDigestSHA256: []string{"092fcfb", "252f10c"},
+				SafetyNetBasicIntegrity:  false,
+				SafetyNetCTSProfileMatch: false,
+			},
+		},
+
+		{
+			name: "safetynet_past_seconds",
 			sql: `
 				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
-					allowed_past_seconds
+					safetynet_past_seconds
 				) VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
+			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &AuthorizedApp{
-				AppPackageName:  "myapp",
-				Platform:        "ios",
-				AllowedRegions:  map[string]struct{}{"US": {}},
-				CTSProfileMatch: true,
-				BasicIntegrity:  true,
-				AllowedPastTime: 30 * time.Minute,
+				AppPackageName:           "myapp",
+				Platform:                 "android",
+				AllowedRegions:           map[string]struct{}{"US": {}},
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: true,
+				SafetyNetPastTime:        30 * time.Minute,
 			},
 		},
 		{
-			name: "allowed_future_time",
+			name: "safetynet_future_seconds",
 			sql: `
 				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
-					allowed_future_seconds
+					safetynet_future_seconds
 				) VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
+			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &AuthorizedApp{
-				AppPackageName:    "myapp",
-				Platform:          "ios",
-				AllowedRegions:    map[string]struct{}{"US": {}},
-				CTSProfileMatch:   true,
-				BasicIntegrity:    true,
-				AllowedFutureTime: 30 * time.Minute,
+				AppPackageName:           "myapp",
+				Platform:                 "android",
+				AllowedRegions:           map[string]struct{}{"US": {}},
+				SafetyNetBasicIntegrity:  true,
+				SafetyNetCTSProfileMatch: true,
+				SafetyNetFutureTime:      30 * time.Minute,
 			},
 		},
 		{
-			name: "ios_devicecheck",
+			name: "devicecheck",
 			sql: `
 				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
-					ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
+					devicecheck_team_id_secret, devicecheck_key_id_secret, devicecheck_private_key_secret
 				) VALUES ($1, $2, $3, $4, $5, $6)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, "team_id", "key_id", "private_key"},
 			exp: &AuthorizedApp{
-				AppPackageName:        "myapp",
-				Platform:              "ios",
-				AllowedRegions:        map[string]struct{}{"US": {}},
-				CTSProfileMatch:       true,
-				BasicIntegrity:        true,
-				DeviceCheckTeamID:     "ABCD1234",
-				DeviceCheckKeyID:      "DEFG5678",
-				DeviceCheckPrivateKey: p8PrivateKey,
+				AppPackageName:           "myapp",
+				Platform:                 "ios",
+				AllowedRegions:           map[string]struct{}{"US": {}},
+				SafetyNetCTSProfileMatch: true,
+				SafetyNetBasicIntegrity:  true,
+				DeviceCheckTeamID:        "ABCD1234",
+				DeviceCheckKeyID:         "DEFG5678",
+				DeviceCheckPrivateKey:    p8PrivateKey,
 			},
 		},
 		{
diff --git a/internal/verification/verify_test.go b/internal/verification/verify_test.go
index c608e11..51ea560 100644
--- a/internal/verification/verify_test.go
+++ b/internal/verification/verify_test.go
@@ -30,68 +30,95 @@ const (
 )
 
 func TestVerifyRegions(t *testing.T) {
-	allRegions := &database.AuthorizedApp{
-		AppPackageName:  appPkgName,
-		AllowAllRegions: true,
-	}
-	usCaRegions := &database.AuthorizedApp{
-		AppPackageName: appPkgName,
-		AllowedRegions: make(map[string]struct{}),
-	}
-	usCaRegions.AllowedRegions["US"] = struct{}{}
-	usCaRegions.AllowedRegions["CA"] = struct{}{}
-
 	cases := []struct {
-		Data *database.Publish
-		Msg  string
-		Cfg  *database.AuthorizedApp
+		name string
+		data *database.Publish
+		cfg  *database.AuthorizedApp
+		err  bool
 	}{
 		{
-			&database.Publish{Regions: []string{"US"}},
-			"no allowed regions configured",
-			nil,
+			name: "nil_config",
+			data: &database.Publish{Regions: []string{"US"}},
+			cfg:  nil,
+			err:  true,
 		},
 		{
-			&database.Publish{Regions: []string{"US"}},
-			"",
-			allRegions,
+			name: "nil_regions_allows_all",
+			data: &database.Publish{Regions: []string{"US"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+			},
 		},
 		{
-			&database.Publish{Regions: []string{"US"}},
-			"",
-			usCaRegions,
+			name: "empty_regions_allows_all",
+			data: &database.Publish{Regions: []string{"US"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+				AllowedRegions: map[string]struct{}{},
+			},
 		},
 		{
-			&database.Publish{Regions: []string{"US", "CA"}},
-			"",
-			usCaRegions,
+			name: "region_matches_one",
+			data: &database.Publish{Regions: []string{"US"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+				AllowedRegions: map[string]struct{}{
+					"US": {},
+					"CA": {},
+				},
+			},
+		},
+		{
+			name: "region_matches_all",
+			data: &database.Publish{Regions: []string{"US", "CA"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+				AllowedRegions: map[string]struct{}{
+					"US": {},
+					"CA": {},
+				},
+			},
 		},
 		{
-			&database.Publish{Regions: []string{"MX"}},
-			fmt.Sprintf("application '%v' tried to write unauthorized region: '%v'", appPkgName, "MX"),
-			usCaRegions,
+			name: "region_matches_some",
+			data: &database.Publish{Regions: []string{"US", "MX"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+				AllowedRegions: map[string]struct{}{
+					"US": {},
+					"CA": {},
+				},
+			},
+			err: true,
+		},
+		{
+			name: "region_matches_none",
+			data: &database.Publish{Regions: []string{"MX"}},
+			cfg: &database.AuthorizedApp{
+				AppPackageName: appPkgName,
+				AllowedRegions: map[string]struct{}{
+					"US": {},
+					"CA": {},
+				},
+			},
+			err: true,
 		},
 	}
 
-	for i, c := range cases {
-		err := VerifyRegions(c.Cfg, c.Data)
-		if c.Msg == "" && err == nil {
-			continue
-		}
-		if c.Msg == "" && err != nil {
-			t.Errorf("%v got %v, wanted no error", i, err)
-			continue
-		}
-		if err.Error() != c.Msg {
-			t.Errorf("%v wrong error, got %v, want %v", i, err, c.Msg)
-		}
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			if err := VerifyRegions(tc.cfg, tc.data); (err != nil) != tc.err {
+				t.Fatal(err)
+			}
+		})
 	}
 }
 
 func TestVerifySafetyNet(t *testing.T) {
 	allRegions := &database.AuthorizedApp{
-		AppPackageName:  appPkgName,
-		AllowAllRegions: true,
+		AppPackageName: appPkgName,
 	}
 
 	cases := []struct {
diff --git a/testdata/config.sql b/testdata/config.sql
index 3ad0868..60428b4 100644
--- a/testdata/config.sql
+++ b/testdata/config.sql
@@ -1,6 +1,29 @@
+-- Copyright 2020 Google LLC
+--
+-- 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.
 
 -- Use this as a template to inject configurations for your publish API endpoint.
 
-INSERT INTO apiconfig (app_package_name, platform, cts_profile_match, basic_integrity, allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions, ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret) VALUES
-  ('com.example.ios.app', 'ios', false, false, 60, 60, ARRAY ['GB','US'], false, 'projects/38554818207/secrets/ios-devicecheck-team-id/versions/1', 'projects/38554818207/secrets/ios-devicecheck-key-id/versions/1', 'projects/38554818207/secrets/ios-devicecheck-private-key/versions/1'),
-  ('com.example.android.app', 'android', false, false, 60, 60, ARRAY ['GB','US'], false, NULL, NULL, NULL);
+INSERT INTO AuthorizedApp (
+  app_package_name, platform, allowed_regions,
+  safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+  devicecheck_team_id_secret, devicecheck_key_id_secret, devicecheck_private_key_secret
+) VALUES (
+  'com.example.ios.app', 'ios', ARRAY[]::VARCHAR[],
+  false, false, 60, 60,
+  'projects/38554818207/secrets/ios-devicecheck-team-id/versions/1', 'projects/38554818207/secrets/ios-devicecheck-key-id/versions/1', 'projects/38554818207/secrets/ios-devicecheck-private-key/versions/1'
+), (
+  'com.example.android.app', 'android', ARRAY[]::VARCHAR[],
+  false, false, 60, 60,
+  NULL, NULL, NULL
+);