Repository: google/exposure-notifications-server
Pull request #262 (2020-05-14 20:23:46)

Move iOS verification into the database layer
This introduces the ability specify per-app iOS DeviceCheck settings by adding new database columns. It also cleans up a few default/null values and provides plumbing for resolving secret values in the data layer.

Fixes GH-170

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/262#" title="Author self-approved">sethvargo</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **crwilcox**
You can assign the PR to them by writing `/assign @crwilcox` in a comment when ready.

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

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

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

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"]} -->
sethvargo: /assign @mikehelmick 
google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/262#" title="Author self-approved">sethvargo</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **mikehelmick**
You can assign the PR to them by writing `/assign @mikehelmick` in a comment when ready.

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

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

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

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":["mikehelmick"]} -->
sethvargo: /assign @mikehelmick 
mikehelmick: /lgtm
/approve
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/262#issuecomment-628972860" title="Approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/262#" title="Author self-approved">sethvargo</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:

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

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":[]} -->

Patch:
diff --git a/cmd/export/main.go b/cmd/export/main.go
index 255b283..7ef0144 100644
--- a/cmd/export/main.go
+++ b/cmd/export/main.go
@@ -32,7 +32,7 @@ func main() {
 	var config export.Config
 	env, closer, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatal("setup.Setup: %v", err)
+		logger.Fatalf("setup.Setup: %v", err)
 	}
 	defer closer()
 
diff --git a/cmd/exposure/main.go b/cmd/exposure/main.go
index 633441e..bc0fbec 100644
--- a/cmd/exposure/main.go
+++ b/cmd/exposure/main.go
@@ -33,7 +33,7 @@ func main() {
 	var config publish.Config
 	env, closer, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatal("setup.Setup: %v", err)
+		logger.Fatalf("setup.Setup: %v", err)
 	}
 	defer closer()
 
diff --git a/cmd/federationin/main.go b/cmd/federationin/main.go
index e7bd6ca..4e8ed43 100644
--- a/cmd/federationin/main.go
+++ b/cmd/federationin/main.go
@@ -33,7 +33,7 @@ func main() {
 	var config federationin.Config
 	env, closer, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatal("setup.Setup: %v", err)
+		logger.Fatalf("setup.Setup: %v", err)
 	}
 	defer closer()
 
diff --git a/cmd/federationout/main.go b/cmd/federationout/main.go
index 8f8c2e4..32da149 100644
--- a/cmd/federationout/main.go
+++ b/cmd/federationout/main.go
@@ -36,7 +36,7 @@ func main() {
 	var config federationout.Config
 	env, closer, err := setup.Setup(ctx, &config)
 	if err != nil {
-		logger.Fatal("setup.Setup: %v", err)
+		logger.Fatalf("setup.Setup: %v", err)
 	}
 	defer closer()
 
diff --git a/config/100-exposure-server.yaml b/config/100-exposure-server.yaml
index 6cabc4f..0d54c9d 100644
--- a/config/100-exposure-server.yaml
+++ b/config/100-exposure-server.yaml
@@ -24,7 +24,7 @@ spec:
           secretKeyRef:
             key: password
             name: dbpassword
-      - name: DB_DBNAME
+      - name: DB_NAME
         valueFrom:
           configMapKeyRef:
             key: dbname
diff --git a/internal/database/apiconfig.go b/internal/database/apiconfig.go
index 55dd88b..13ee544 100644
--- a/internal/database/apiconfig.go
+++ b/internal/database/apiconfig.go
@@ -16,14 +16,17 @@ package database
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"time"
 
+	"github.com/google/exposure-notifications-server/internal/ios"
 	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 )
 
 // ReadAPIConfigs loads all APIConfig values from the database.
-func (db *DB) ReadAPIConfigs(ctx context.Context) ([]*apiconfig.APIConfig, error) {
+func (db *DB) ReadAPIConfigs(ctx context.Context, sm secrets.SecretManager) ([]*apiconfig.APIConfig, error) {
 	conn, err := db.pool.Acquire(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("acquiring connection: %v", err)
@@ -31,11 +34,12 @@ func (db *DB) ReadAPIConfigs(ctx context.Context) ([]*apiconfig.APIConfig, error
 	defer conn.Release()
 
 	query := `
-	    SELECT
-	    	app_package_name, platform, apk_digest, cts_profile_match, basic_integrity,
-        allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions
-	    FROM
-	    	APIConfig`
+		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
+		FROM
+			APIConfig`
 	rows, err := conn.Query(ctx, query)
 	if err != nil {
 		return nil, err
@@ -46,16 +50,17 @@ func (db *DB) ReadAPIConfigs(ctx context.Context) ([]*apiconfig.APIConfig, error
 	var result []*apiconfig.APIConfig
 	for rows.Next() {
 		if err := rows.Err(); err != nil {
-			return nil, fmt.Errorf("iterating rows: %v", err)
+			return nil, fmt.Errorf("iterating rows: %w", err)
 		}
 
 		var regions []string
 		config := apiconfig.New()
 		var allowedPastSeconds, allowedFutureSeconds *int
+		var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
 		if err := rows.Scan(&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256,
 			&config.CTSProfileMatch, &config.BasicIntegrity,
-			&allowedPastSeconds, &allowedFutureSeconds, &regions,
-			&config.AllowAllRegions); err != nil {
+			&allowedPastSeconds, &allowedFutureSeconds, &regions, &config.AllowAllRegions,
+			&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret); err != nil {
 			return nil, err
 		}
 
@@ -74,6 +79,40 @@ func (db *DB) ReadAPIConfigs(ctx context.Context) ([]*apiconfig.APIConfig, error
 			config.AllowedRegions[r] = true
 		}
 
+		// Resolve secrets to their plaintext values
+		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",
+					config.AppPackageName, config.Platform, err)
+			}
+			config.DeviceCheckTeamID = plaintext
+		}
+
+		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",
+					config.AppPackageName, config.Platform, err)
+			}
+			config.DeviceCheckKeyID = plaintext
+		}
+
+		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",
+					config.AppPackageName, config.Platform, err)
+			}
+
+			key, err := ios.ParsePrivateKey(plaintext)
+			if err != nil {
+				return nil, fmt.Errorf("failed to parse private key at %s (%s): %w",
+					config.AppPackageName, config.Platform, err)
+			}
+			config.DeviceCheckPrivateKey = key
+		}
+
 		result = append(result, config)
 	}
 	if err := rows.Err(); err != nil {
diff --git a/internal/database/environment.go b/internal/database/environment.go
index 47d9c4d..e28cdc7 100644
--- a/internal/database/environment.go
+++ b/internal/database/environment.go
@@ -17,7 +17,7 @@ package database
 import "time"
 
 type Config struct {
-	Name               string        `envconfig:"DB_DBNAME"`
+	Name               string        `envconfig:"DB_NAME"`
 	User               string        `envconfig:"DB_USER"`
 	Host               string        `envconfig:"DB_HOST" default:"localhost"`
 	Port               string        `envconfig:"DB_PORT" default:"5432"`
diff --git a/internal/dbapiconfig/apiconfig.go b/internal/dbapiconfig/apiconfig.go
index 654dc46..f41766f 100644
--- a/internal/dbapiconfig/apiconfig.go
+++ b/internal/dbapiconfig/apiconfig.go
@@ -16,7 +16,6 @@ package dbapiconfig
 
 import (
 	"context"
-	"crypto/ecdsa"
 	"fmt"
 	"sync"
 	"time"
@@ -25,6 +24,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 )
 
 // Compile-time check to assert Config implements the Provider interface.
@@ -32,40 +32,33 @@ var _ provider.Provider = (*dbConfigProvider)(nil)
 
 type dbConfigProvider struct {
 	db            *database.DB
+	sm            secrets.SecretManager
 	refreshPeriod time.Duration
 
 	bypassSafetyNet   bool
 	bypassDeviceCheck bool
 
-	deviceCheckKeyID      string
-	deviceCheckTeamID     string
-	deviceCheckPrivateKey *ecdsa.PrivateKey
-
 	mu           sync.RWMutex
 	lastLoadTime time.Time
 	cache        map[string]*apiconfig.APIConfig
 }
 
 type ConfigOpts struct {
-	RefreshDuration       time.Duration `envconfig:"API_CONFIG_REFRESH" default:"5m"`
-	BypassSafetyNet       bool          `envconfig:"BYPASS_SAFETYNET"`
-	BypassDeviceCheck     bool          `envconfig:"BYPASS_DEVICECHECK"`
-	DeviceCheckKeyID      string        `envconfig:"DEVICECHECK_KEY_ID"`
-	DeviceCheckTeamID     string        `envconfig:"DEVICECHECK_TEAM_ID"`
-	DeviceCheckPrivateKey string        `envconfig:"DEVICECHECK_PRIVATE_KEY"`
+	RefreshDuration   time.Duration `envconfig:"API_CONFIG_REFRESH" default:"5m"`
+	BypassSafetyNet   bool          `envconfig:"BYPASS_SAFETYNET"`
+	BypassDeviceCheck bool          `envconfig:"BYPASS_DEVICECHECK"`
 }
 
-func NewConfigProvider(db *database.DB, opts *ConfigOpts) (provider.Provider, error) {
+func NewConfigProvider(db *database.DB, sm secrets.SecretManager, opts *ConfigOpts) (provider.Provider, error) {
 	ctx := context.Background()
 	logger := logging.FromContext(ctx)
 
 	cfg := &dbConfigProvider{
 		db:                db,
+		sm:                sm,
 		refreshPeriod:     opts.RefreshDuration,
 		bypassSafetyNet:   opts.BypassSafetyNet,
 		bypassDeviceCheck: opts.BypassDeviceCheck,
-		deviceCheckKeyID:  opts.DeviceCheckKeyID,
-		deviceCheckTeamID: opts.DeviceCheckTeamID,
 		cache:             make(map[string]*apiconfig.APIConfig),
 	}
 
@@ -100,7 +93,7 @@ func (c *dbConfigProvider) loadConfig(ctx context.Context) error {
 
 	logger := logging.FromContext(ctx)
 
-	configs, err := c.db.ReadAPIConfigs(ctx)
+	configs, err := c.db.ReadAPIConfigs(ctx, c.sm)
 	if err != nil {
 		return err
 	}
diff --git a/internal/ios/devicecheck.go b/internal/ios/devicecheck.go
index 8b8401a..0961090 100644
--- a/internal/ios/devicecheck.go
+++ b/internal/ios/devicecheck.go
@@ -59,6 +59,11 @@ func ValidateDeviceToken(ctx context.Context, deviceToken string, opts *VerifyOp
 		return fmt.Errorf("devicecheck: missing private key")
 	}
 
+	// Verify we got a token.
+	if deviceToken == "" {
+		return fmt.Errorf("devicecheck: missing device token")
+	}
+
 	// Generate a JWT.
 	signedJwt, err := newSignedJWT(opts.TeamID, opts.KeyID, opts.PrivateKey)
 	if err != nil {
diff --git a/internal/secrets/gcp_secret_manager.go b/internal/secrets/gcp_secret_manager.go
index 776dfab..1e6632e 100644
--- a/internal/secrets/gcp_secret_manager.go
+++ b/internal/secrets/gcp_secret_manager.go
@@ -19,6 +19,8 @@ package secrets
 import (
 	"context"
 	"fmt"
+	"sync"
+	"time"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
 
@@ -26,34 +28,91 @@ import (
 	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
 )
 
+const (
+	// defaultCacheDuration is how long to cache a secret.
+	defaultCacheDuration = 5 * time.Minute
+)
+
 type GCPSecretManager struct {
 	client *secretmanager.Client
+
+	cache         map[string]*item
+	cacheDuration time.Duration
+	cacheMutex    sync.Mutex
+}
+
+type item struct {
+	value     string
+	expiresAt int64
+}
+
+// Option is an option to the setup.
+type Option func(*GCPSecretManager) *GCPSecretManager
+
+// WithCacheDuration creates an Option to install a specific secret manager to use.
+func WithCacheDuration(d time.Duration) Option {
+	return func(s *GCPSecretManager) *GCPSecretManager {
+		s.cacheDuration = d
+		return s
+	}
 }
 
-func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
+func NewGCPSecretManager(ctx context.Context, opts ...Option) (SecretManager, error) {
 	client, err := secretmanager.NewClient(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("secretmanager.NewClient: %w", err)
 	}
 
-	return &GCPSecretManager{client}, nil
+	sm := &GCPSecretManager{
+		client:        client,
+		cache:         make(map[string]*item),
+		cacheDuration: defaultCacheDuration,
+	}
+
+	for _, f := range opts {
+		sm = f(sm)
+	}
+
+	return sm, nil
 }
 
+// GetSecretValue implements the SecretManager interface.
 func (sm *GCPSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
 	logger := logging.FromContext(ctx)
 
-	// Build the request.
-	accessRequest := &secretmanagerpb.AccessSecretVersionRequest{
-		Name: name,
+	// Lock
+	sm.cacheMutex.Lock()
+	defer sm.cacheMutex.Unlock()
+
+	// Lookup in cache
+	if i, ok := sm.cache[name]; ok && i.expiresAt <= time.Now().UnixNano() {
+		logger.Debugf("loaded secret %v from cache", name)
+		return i.value, nil
 	}
 
-	// Call the API.
-	result, err := sm.client.AccessSecretVersion(ctx, accessRequest)
+	// Lookup in secret manager
+	plaintext, err := sm.accessSecret(ctx, name)
 	if err != nil {
-		return "", fmt.Errorf("failed to access secret version for %v: %w", name, err)
+		return "", err
+	}
+
+	// Cache value
+	sm.cache[name] = &item{
+		value:     plaintext,
+		expiresAt: time.Now().Add(sm.cacheDuration).UnixNano(),
 	}
-	logger.Infof("loaded secret value for %v", name)
 
-	plaintext := string(result.Payload.Data)
 	return plaintext, nil
 }
+
+// accessSecret accesses the secret from secret manager.
+func (sm *GCPSecretManager) accessSecret(ctx context.Context, name string) (string, error) {
+	// Call the API.
+	result, err := sm.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
+		Name: name,
+	})
+	if err != nil {
+		return "", fmt.Errorf("failed to access secret %v: %w", name, err)
+	}
+	return string(result.Payload.Data), nil
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 118be70..b9ec56a 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -96,7 +96,7 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 	opts = append(opts, serverenv.WithDatabase(db))
 
 	if apicfg, ok := config.(DBAPIConfigProvider); ok {
-		cfgProvider, err := dbapiconfig.NewConfigProvider(db, apicfg.API())
+		cfgProvider, err := dbapiconfig.NewConfigProvider(db, sm, apicfg.API())
 		if err != nil {
 			// APIConfig must come after DB due to dependency, ensure connection is closed
 			defer db.Close(ctx)
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index dc7fc3d..a080705 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -83,7 +83,6 @@ func VerifyDeviceCheck(ctx context.Context, cfg *apiconfig.APIConfig, data *mode
 		return fmt.Errorf("cannot enforce devicecheck, missing config")
 	}
 
-	// TODO(sethvargo): Pull these values from cfg and plumb through.
 	opts := &ios.VerifyOpts{
 		KeyID:      cfg.DeviceCheckKeyID,
 		TeamID:     cfg.DeviceCheckTeamID,
diff --git a/migrations/000014_ios_per_app_config.down.sql b/migrations/000014_ios_per_app_config.down.sql
new file mode 100644
index 0000000..7173fdf
--- /dev/null
+++ b/migrations/000014_ios_per_app_config.down.sql
@@ -0,0 +1,22 @@
+-- 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 APIConfig
+  DROP COLUMN ios_devicecheck_team_id_secret,
+  DROP COLUMN ios_devicecheck_key_id_secret,
+  DROP COLUMN ios_devicecheck_private_key_secret;
+
+END;
diff --git a/migrations/000014_ios_per_app_config.up.sql b/migrations/000014_ios_per_app_config.up.sql
new file mode 100644
index 0000000..dcc4d17
--- /dev/null
+++ b/migrations/000014_ios_per_app_config.up.sql
@@ -0,0 +1,22 @@
+-- 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 APIConfig
+  ADD COLUMN ios_devicecheck_team_id_secret VARCHAR(255),
+  ADD COLUMN ios_devicecheck_key_id_secret VARCHAR(255),
+  ADD COLUMN ios_devicecheck_private_key_secret VARCHAR(255);
+
+END;
diff --git a/migrations/000015_cts_integ_default.down.sql b/migrations/000015_cts_integ_default.down.sql
new file mode 100644
index 0000000..4116ad3
--- /dev/null
+++ b/migrations/000015_cts_integ_default.down.sql
@@ -0,0 +1,26 @@
+-- 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 APIConfig ALTER COLUMN cts_profile_match DROP DEFAULT;
+ALTER TABLE APIConfig ALTER COLUMN cts_profile_match SET NOT NULL;
+
+ALTER TABLE APIConfig ALTER COLUMN basic_integrity DROP DEFAULT;
+ALTER TABLE APIConfig ALTER COLUMN basic_integrity SET NOT NULL;
+
+ALTER TABLE APIConfig ALTER COLUMN all_regions DROP DEFAULT;
+ALTER TABLE APIConfig ALTER COLUMN all_regions SET NOT NULL;
+
+END;
diff --git a/migrations/000015_cts_integ_default.up.sql b/migrations/000015_cts_integ_default.up.sql
new file mode 100644
index 0000000..9efde78
--- /dev/null
+++ b/migrations/000015_cts_integ_default.up.sql
@@ -0,0 +1,26 @@
+-- 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 APIConfig ALTER COLUMN cts_profile_match SET DEFAULT true;
+ALTER TABLE APIConfig ALTER COLUMN cts_profile_match DROP NOT NULL;
+
+ALTER TABLE APIConfig ALTER COLUMN basic_integrity SET DEFAULT true;
+ALTER TABLE APIConfig ALTER COLUMN basic_integrity DROP NOT NULL;
+
+ALTER TABLE APIConfig ALTER COLUMN all_regions SET DEFAULT false;
+ALTER TABLE APIConfig ALTER COLUMN all_regions DROP NOT NULL;
+
+END;
diff --git a/terraform/main.tf b/terraform/main.tf
index a684661..80e4ad7 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -205,13 +205,13 @@ resource "google_cloudbuild_trigger" "update-schema" {
     }
   }
   substitutions = {
-    "_HOST": "localhost"
-    "_CLOUDSQLPATH": "${data.google_project.project.project_id}:${var.region}:${google_sql_database_instance.db-inst.name}"
-    "_PORT": "5432"
-    "_PASSWORD_SECRET": google_secret_manager_secret.db-pwd.secret_id
-    "_USER": google_sql_user.user.name
-    "_NAME": google_sql_database.db.name
-    "_SSLMODE": "disable"
+    "_HOST" : "localhost"
+    "_CLOUDSQLPATH" : "${data.google_project.project.project_id}:${var.region}:${google_sql_database_instance.db-inst.name}"
+    "_PORT" : "5432"
+    "_PASSWORD_SECRET" : google_secret_manager_secret.db-pwd.secret_id
+    "_USER" : google_sql_user.user.name
+    "_NAME" : google_sql_database.db.name
+    "_SSLMODE" : "disable"
   }
   provisioner "local-exec" {
     command = "gcloud builds submit ../ --config ../builders/schema.yaml --project ${data.google_project.project.project_id} --substitutions=_HOST=${google_sql_database_instance.db-inst.public_ip_address},_PORT=5432,_PASSWORD_SECRET=${google_secret_manager_secret.db-pwd.secret_id},_USER=${google_sql_user.user.name},_NAME=${google_sql_database.db.name},_SSLMODE=disable,_CLOUDSQLPATH=${data.google_project.project.project_id}:${var.region}:${google_sql_database_instance.db-inst.name}"
@@ -304,7 +304,7 @@ resource "google_cloud_run_service" "exposure" {
           value = google_sql_user.user.name
         }
         env {
-          name  = "DB_DBNAME"
+          name  = "DB_NAME"
           value = google_sql_database.db.name
         }
       }
@@ -312,11 +312,11 @@ resource "google_cloud_run_service" "exposure" {
     metadata {
       annotations = {
         "run.googleapis.com/cloudsql-instances" : "${data.google_project.project.project_id}:${var.region}:${google_sql_database_instance.db-inst.name}"
-        "autoscaling.knative.dev/maxScale"      : "1000"
+        "autoscaling.knative.dev/maxScale" : "1000"
       }
     }
   }
-	depends_on = [google_cloudbuild_trigger.build-and-publish]
+  depends_on = [google_cloudbuild_trigger.build-and-publish]
 }
 
 resource "google_cloud_run_service" "export" {
@@ -371,7 +371,7 @@ resource "google_cloud_run_service" "export" {
           value = google_sql_user.user.name
         }
         env {
-          name  = "DB_DBNAME"
+          name  = "DB_NAME"
           value = google_sql_database.db.name
         }
       }
@@ -379,11 +379,11 @@ resource "google_cloud_run_service" "export" {
     metadata {
       annotations = {
         "run.googleapis.com/cloudsql-instances" : "${data.google_project.project.project_id}:${var.region}:${google_sql_database_instance.db-inst.name}"
-        "autoscaling.knative.dev/maxScale"      : "1000"
+        "autoscaling.knative.dev/maxScale" : "1000"
       }
     }
   }
-	depends_on = [google_cloudbuild_trigger.build-and-publish]
+  depends_on = [google_cloudbuild_trigger.build-and-publish]
 }
 
 


Test patch:
diff --git a/internal/database/apiconfig_test.go b/internal/database/apiconfig_test.go
new file mode 100644
index 0000000..58c6e42
--- /dev/null
+++ b/internal/database/apiconfig_test.go
@@ -0,0 +1,192 @@
+// 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.
+
+package database
+
+import (
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+type testSecretManager struct {
+	values map[string]string
+}
+
+func (s *testSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
+	v, ok := s.values[name]
+	if !ok {
+		return "", fmt.Errorf("missing %q", name)
+	}
+	return v, nil
+}
+
+func TestReadAPIConfigs(t *testing.T) {
+	if testDB == nil {
+		t.Skip("no test DB")
+	}
+	defer resetTestDB(t)
+	ctx := context.Background()
+
+	// Create private key for parsing later
+	p8PrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	derKey, err := x509.MarshalPKCS8PrivateKey(p8PrivateKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pemBytes := pem.EncodeToMemory(&pem.Block{
+		Type:  "PRIVATE KEY",
+		Bytes: derKey,
+	})
+
+	sm := &testSecretManager{
+		values: map[string]string{
+			"team_id":     "ABCD1234",
+			"key_id":      "DEFG5678",
+			"private_key": string(pemBytes),
+		},
+	}
+
+	cases := []struct {
+		name string
+		sql  string
+		args []interface{}
+		exp  []*apiconfig.APIConfig
+		err  bool
+	}{
+		{
+			name: "bare",
+			sql: `
+				INSERT INTO APIConfig (app_package_name, platform, allowed_regions)
+				VALUES ($1, $2, $3)
+			`,
+			args: []interface{}{"myapp", "ios", []string{"US"}},
+			exp: []*apiconfig.APIConfig{
+				{
+					AppPackageName:  "myapp",
+					Platform:        "ios",
+					AllowedRegions:  map[string]bool{"US": true},
+					CTSProfileMatch: true,
+					BasicIntegrity:  true,
+				},
+			},
+		},
+		{
+			name: "allowed_past_time",
+			sql: `
+				INSERT INTO APIConfig (
+					app_package_name, platform, allowed_regions,
+					allowed_past_seconds
+				) VALUES ($1, $2, $3, $4)
+			`,
+			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
+			exp: []*apiconfig.APIConfig{
+				{
+					AppPackageName:  "myapp",
+					Platform:        "ios",
+					AllowedRegions:  map[string]bool{"US": true},
+					CTSProfileMatch: true,
+					BasicIntegrity:  true,
+					AllowedPastTime: 30 * time.Minute,
+				},
+			},
+		},
+		{
+			name: "allowed_future_time",
+			sql: `
+				INSERT INTO APIConfig (
+					app_package_name, platform, allowed_regions,
+					allowed_future_seconds
+				) VALUES ($1, $2, $3, $4)
+			`,
+			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
+			exp: []*apiconfig.APIConfig{
+				{
+					AppPackageName:    "myapp",
+					Platform:          "ios",
+					AllowedRegions:    map[string]bool{"US": true},
+					CTSProfileMatch:   true,
+					BasicIntegrity:    true,
+					AllowedFutureTime: 30 * time.Minute,
+				},
+			},
+		},
+		{
+			name: "ios_devicecheck",
+			sql: `
+				INSERT INTO APIConfig (
+					app_package_name, platform, allowed_regions,
+					ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
+				) VALUES ($1, $2, $3, $4, $5, $6)
+			`,
+			args: []interface{}{"myapp", "ios", []string{"US"}, "team_id", "key_id", "private_key"},
+			exp: []*apiconfig.APIConfig{
+				{
+					AppPackageName:        "myapp",
+					Platform:              "ios",
+					AllowedRegions:        map[string]bool{"US": true},
+					CTSProfileMatch:       true,
+					BasicIntegrity:        true,
+					DeviceCheckTeamID:     "ABCD1234",
+					DeviceCheckKeyID:      "DEFG5678",
+					DeviceCheckPrivateKey: p8PrivateKey,
+				},
+			},
+		},
+	}
+
+	for _, c := range cases {
+		c := c
+
+		t.Run(c.name, func(t *testing.T) {
+			// Acquire a connection
+			conn, err := testDB.pool.Acquire(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+			defer conn.Release()
+
+			// Insert the data
+			if _, err := conn.Exec(ctx, c.sql, c.args...); err != nil {
+				t.Fatal(err)
+			}
+
+			configs, err := testDB.ReadAPIConfigs(ctx, sm)
+			if (err != nil) != c.err {
+				t.Fatal(err)
+			}
+
+			// Compare, ignoring the private key part
+			opts := cmpopts.IgnoreTypes(new(ecdsa.PrivateKey))
+			if diff := cmp.Diff(configs, c.exp, opts); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+
+			resetTestDB(t)
+		})
+	}
+}
diff --git a/internal/ios/devicecheck_test.go b/internal/ios/devicecheck_test.go
index 6c46bdd..1b30126 100644
--- a/internal/ios/devicecheck_test.go
+++ b/internal/ios/devicecheck_test.go
@@ -105,6 +105,14 @@ func TestValidateDeviceToken(t *testing.T) {
 			privateKey:  nil,
 			err:         true,
 		},
+		{
+			name:        "no token",
+			deviceToken: "",
+			teamID:      teamID,
+			keyID:       keyID,
+			privateKey:  privateKey,
+			err:         true,
+		},
 	}
 
 	for _, c := range cases {
diff --git a/testdata/config.sql b/testdata/config.sql
index 0a47eb2..3ad0868 100644
--- a/testdata/config.sql
+++ b/testdata/config.sql
@@ -1,6 +1,6 @@
 
 -- 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) VALUES
-  ('com.example.ios.app', 'ios', false, false, 60, 60, ARRAY ['GB','US'], false),
-  ('com.example.android.app', 'android', false, false, 60, 60, ARRAY ['GB','US'], false);
+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);