Repository: google/exposure-notifications-server
Pull request #395 (2020-05-21 00:34:54)

Admin Console - Implement AuthorizedApps editing
/hold

This is very much in progress - but wanted to show current status.

This provides a Web UI for configuring the server.

Took a shot at a CLI, but got very complicated very quickly, wanted to show this approach.

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/395#" title="Author self-approved">mikehelmick</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":[]} -->
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/395#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/395#pullrequestreview-415824533" title="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,sethvargo]

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":[]} -->
sethvargo: /hold
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/395#" title="Author self-approved">mikehelmick</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":[]} -->
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/395#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/395#pullrequestreview-416131629" title="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,sethvargo]

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":[]} -->
sethvargo: /lgtm
google-prow-robot: New changes are detected. LGTM label has been removed.
mikehelmick: /unhold

This is ready for review - represents create/edit/delete working for authorized apps + lots of scaffolding for the UI.
mikehelmick: #370 
#382 
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/395#pullrequestreview-416628522" title="Approved">crwilcox</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/395#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/395#pullrequestreview-416131629" title="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)~~ [crwilcox,mikehelmick,sethvargo]

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/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go
index 8bc3c68..df33991 100644
--- a/internal/authorizedapp/database/authorized_app.go
+++ b/internal/authorizedapp/database/authorized_app.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"database/sql"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
@@ -37,6 +38,95 @@ func New(db *database.DB) *AuthorizedAppDB {
 	}
 }
 
+func (aa *AuthorizedAppDB) InsertAuthorizedApp(ctx context.Context, m *model.AuthorizedApp) error {
+	if errors := m.Validate(); len(errors) > 0 {
+		return fmt.Errorf("AuthorizedApp invalid: %v", strings.Join(errors, ", "))
+	}
+
+	return aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			INSERT INTO
+				AuthorizedApp
+			  (app_package_name, platform, allowed_regions,
+			   safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+			   devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret)
+			VALUES
+			  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+		`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
+			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret)
+
+		if err != nil {
+			return fmt.Errorf("inserting authorizedapp: %w", err)
+		}
+		if result.RowsAffected() != 1 {
+			return fmt.Errorf("no rows inserted")
+		}
+		return nil
+	})
+}
+
+func (aa *AuthorizedAppDB) DeleteAuthorizedApp(ctx context.Context, appPackageName string) error {
+	var count int64
+	err := aa.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			DELETE FROM
+				AuthorizedApp
+			WHERE
+				app_package_name = $1
+			`, appPackageName)
+		if err != nil {
+			return fmt.Errorf("deleting authorized app: %w", err)
+		}
+		count = result.RowsAffected()
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	if count == 0 {
+		return fmt.Errorf("no rows were deleted")
+	}
+	return nil
+}
+
+func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.SecretManager) ([]*model.AuthorizedApp, error) {
+	conn, err := aa.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	query := `
+    SELECT
+      app_package_name, platform, allowed_regions,
+      safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+      devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
+    FROM
+      AuthorizedApp
+    ORDER BY app_package_name ASC`
+
+	rows, err := conn.Query(ctx, query)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var result []*model.AuthorizedApp
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, fmt.Errorf("iterating rows: %w", err)
+		}
+
+		app, err := scanOneAuthorizedApp(ctx, rows, sm)
+		if err != nil {
+			return nil, fmt.Errorf("error reading authorized apps: %w", err)
+		}
+		result = append(result, app)
+	}
+	return result, nil
+}
+
 // GetAuthorizedApp loads a single AuthorizedApp for the given name. If no row
 // exists, this returns nil.
 func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, name string) (*model.AuthorizedApp, error) {
@@ -57,6 +147,10 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 
 	row := conn.QueryRow(ctx, query, name)
 
+	return scanOneAuthorizedApp(ctx, row, sm)
+}
+
+func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretManager) (*model.AuthorizedApp, error) {
 	config := model.NewAuthorizedApp()
 	var allowedRegions []string
 	var safetyNetPastSeconds, safetyNetFutureSeconds *int
@@ -98,6 +192,7 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 
 	// Resolve secrets to their plaintext values
 	if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
+		config.DeviceCheckPrivateKeySecret = v.String
 		plaintext, err := sm.GetSecretValue(ctx, v.String)
 		if err != nil {
 			return nil, fmt.Errorf("devicecheck_private_key_secret at %s (%s): %w",
diff --git a/internal/authorizedapp/model/authorized_app_model.go b/internal/authorizedapp/model/authorized_app_model.go
index 33a6c36..876300a 100644
--- a/internal/authorizedapp/model/authorized_app_model.go
+++ b/internal/authorizedapp/model/authorized_app_model.go
@@ -16,6 +16,7 @@ package model
 
 import (
 	"crypto/ecdsa"
+	"strings"
 	"time"
 )
 
@@ -48,10 +49,11 @@ type AuthorizedApp struct {
 	SafetyNetFutureTime      time.Duration
 
 	// DeviceCheck configuration.
-	DeviceCheckDisabled   bool
-	DeviceCheckKeyID      string
-	DeviceCheckTeamID     string
-	DeviceCheckPrivateKey *ecdsa.PrivateKey
+	DeviceCheckDisabled         bool
+	DeviceCheckKeyID            string
+	DeviceCheckTeamID           string
+	DeviceCheckPrivateKey       *ecdsa.PrivateKey
+	DeviceCheckPrivateKeySecret string
 }
 
 func NewAuthorizedApp() *AuthorizedApp {
@@ -60,6 +62,44 @@ func NewAuthorizedApp() *AuthorizedApp {
 	}
 }
 
+func (c *AuthorizedApp) AllAllowedRegions() []string {
+	regions := []string{}
+	for k := range c.AllowedRegions {
+		regions = append(regions, k)
+	}
+	return regions
+}
+
+func (c *AuthorizedApp) Validate() []string {
+	errors := make([]string, 0)
+	if c.AppPackageName == "" {
+		errors = append(errors, "AppPackageName cannot be empty")
+	}
+	if !(c.Platform == "android" || c.Platform == "ios" || c.Platform == "both") {
+		errors = append(errors, "platform must be one if {'android', 'ios', or 'both'}")
+	}
+	if c.SafetyNetDisabled == false {
+		if c.SafetyNetPastTime < 0 {
+			errors = append(errors, "SafetyNetPastTime cannot be negative")
+		}
+		if c.SafetyNetFutureTime < 0 {
+			errors = append(errors, "SafetyNetFutureTime cannot be negative")
+		}
+	}
+	if c.DeviceCheckDisabled == false {
+		if c.DeviceCheckKeyID == "" {
+			errors = append(errors, "When DeviceCheck is enabled, DeviceCheckKeyID cannot be empty")
+		}
+		if c.DeviceCheckTeamID == "" {
+			errors = append(errors, "When DeviceCheck is enabled, DeviceCheckTeamID cannot be empty")
+		}
+		if c.DeviceCheckPrivateKeySecret == "" {
+			errors = append(errors, "When DeviceCheck is enabled, DeviceCheckPrivateKeySecret cannot be empty")
+		}
+	}
+	return errors
+}
+
 // IsIOS returns true if the platform is equal to `iosDevice`
 func (c *AuthorizedApp) IsIOS() bool {
 	return c.Platform == iosDevice || c.Platform == bothPlatforms
@@ -76,6 +116,18 @@ func (c *AuthorizedApp) IsDualPlatform() bool {
 	return c.Platform == bothPlatforms
 }
 
+func (c *AuthorizedApp) RegionsOnePerLine() string {
+	regions := []string{}
+	for r := range c.AllowedRegions {
+		regions = append(regions, r)
+	}
+	return strings.Join(regions, "\n")
+}
+
+func (c *AuthorizedApp) APKDigestOnePerLine() string {
+	return strings.Join(c.SafetyNetApkDigestSHA256, "\n")
+}
+
 // 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 {
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index ba783a8..c70ad89 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -78,6 +78,35 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 	})
 }
 
+func (db *ExportDB) GetAllExportConfigs(ctx context.Context) ([]*model.ExportConfig, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	rows, err := conn.Query(ctx, `
+		SELECT
+			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
+		FROM
+			ExportConfig`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	results := []*model.ExportConfig{}
+	for rows.Next() {
+		ec, err := scanOneExportConfig(rows)
+		if err != nil {
+			return nil, err
+		}
+		results = append(results, ec)
+	}
+
+	return results, nil
+}
+
 // IterateExportConfigs applies f to each ExportConfig whose FromTimestamp is
 // before the given time. If f returns a non-nil error, the iteration stops, and
 // the returned error will match f's error with errors.Is.
@@ -109,25 +138,33 @@ func (db *ExportDB) IterateExportConfigs(ctx context.Context, t time.Time, f fun
 	}
 	defer rows.Close()
 	for rows.Next() {
-		var (
-			m             model.ExportConfig
-			periodSeconds int
-			thru          *time.Time
-		)
-		if err := rows.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &m.Region, &m.From, &thru, &m.SignatureInfoIDs); err != nil {
+		m, err := scanOneExportConfig(rows)
+		if err != nil {
 			return err
 		}
-		m.Period = time.Duration(periodSeconds) * time.Second
-		if thru != nil {
-			m.Thru = *thru
-		}
-		if err := f(&m); err != nil {
+		if err = f(m); err != nil {
 			return err
 		}
 	}
 	return rows.Err()
 }
 
+func scanOneExportConfig(row pgx.Row) (*model.ExportConfig, error) {
+	var (
+		m             model.ExportConfig
+		periodSeconds int
+		thru          *time.Time
+	)
+	if err := row.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &m.Region, &m.From, &thru, &m.SignatureInfoIDs); err != nil {
+		return nil, err
+	}
+	m.Period = time.Duration(periodSeconds) * time.Second
+	if thru != nil {
+		m.Thru = *thru
+	}
+	return &m, nil
+}
+
 func (db *ExportDB) AddSignatureInfo(ctx context.Context, si *model.SignatureInfo) error {
 	if si.SigningKey == "" {
 		return fmt.Errorf("signing key cannot be empty for a signature info")
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 79729da..5756bf8 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -36,6 +36,17 @@ type ExportConfig struct {
 	SignatureInfoIDs []int64       `db:"signature_info_ids"`
 }
 
+func (e *ExportConfig) FormattedFromTime() string {
+	return e.From.Format(time.UnixDate)
+}
+
+func (e *ExportConfig) FormattedThruTime() string {
+	if e.Thru.IsZero() {
+		return ""
+	}
+	return e.Thru.Format(time.UnixDate)
+}
+
 type ExportBatch struct {
 	BatchID          int64     `db:"batch_id" json:"batchID"`
 	ConfigID         int64     `db:"config_id" json:"configID"`
diff --git a/tools/admin-console/authorizedapps.go b/tools/admin-console/authorizedapps.go
new file mode 100644
index 0000000..5c54518
--- /dev/null
+++ b/tools/admin-console/authorizedapps.go
@@ -0,0 +1,234 @@
+// 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, softwar
+// 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 main
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type appHandler struct {
+	config *Config
+	env    *serverenv.ServerEnv
+}
+
+func NewAppHandler(c *Config, env *serverenv.ServerEnv) *appHandler {
+	return &appHandler{config: c, env: env}
+}
+
+func (h *appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.Method == "GET" {
+		h.handleGet(w, r)
+		return
+	} else if r.Method == "POST" {
+		h.handlePost(w, r)
+		return
+	}
+
+	m := TemplateMap{}
+	m.AddErrors("Invalid request.")
+	h.config.RenderTemplate(w, "error", m)
+	return
+}
+
+func (h *appHandler) renderError(m TemplateMap, msg string, w http.ResponseWriter) {
+	m.AddErrors(msg)
+	h.config.RenderTemplate(w, "error", m)
+}
+
+func populateAuthorizedApp(a *model.AuthorizedApp, r *http.Request) []string {
+	a.AppPackageName = r.FormValue("AppPackageName")
+	a.Platform = r.FormValue("Platform")
+
+	a.AllowedRegions = make(map[string]struct{})
+	regions := strings.Split(r.FormValue("Regions"), "\n")
+	for _, region := range regions {
+		a.AllowedRegions[region] = struct{}{}
+	}
+
+	var err error
+	errors := []string{}
+	// SafetyNet pieces.
+	a.SafetyNetDisabled, err = strconv.ParseBool(r.FormValue("SafetyNetDisabled"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("SafetyNetDisabled, invalid value: %v", err))
+	}
+	a.SafetyNetApkDigestSHA256 = strings.Split(r.FormValue("SafetyNetApkDigestSHA256"), "\n")
+	a.SafetyNetBasicIntegrity, err = strconv.ParseBool(r.FormValue("SafetyNetBasicIntegrity"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("SafetyNetBasicIntegrity, invalid value: %v", err))
+	}
+	a.SafetyNetCTSProfileMatch, err = strconv.ParseBool(r.FormValue("SafetyNetCTSProfileMatch"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("SafetyNetCTSProfileMatch, invalid value: %v", err))
+	}
+	a.SafetyNetPastTime, err = time.ParseDuration(r.FormValue("SafetyNetPastTime"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("SafetyNetPastTime, invalid value: %v", err))
+	}
+	a.SafetyNetFutureTime, err = time.ParseDuration(r.FormValue("SafetyNetFutureTime"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("SafetyNetFutureTime, invalid value: %v", err))
+	}
+
+	// DeviceCheck pieces
+	a.DeviceCheckDisabled, err = strconv.ParseBool(r.FormValue("DeviceCheckDisabled"))
+	if err != nil {
+		errors = append(errors, fmt.Sprintf("DeviceCheckDisabled, invalid value: %v", err))
+	}
+	a.DeviceCheckKeyID = r.FormValue("DeviceCheckKeyID")
+	a.DeviceCheckTeamID = r.FormValue("DeviceCheckTeamID")
+	a.DeviceCheckPrivateKeySecret = r.FormValue("DeviceCheckPrivateKeySecret")
+
+	return errors
+}
+
+func decodePriorKey(priorKey string) (string, error) {
+	if priorKey != "" {
+		bytes, err := base64.StdEncoding.DecodeString(priorKey)
+		if err != nil {
+			return "", err
+		}
+		priorKey = string(bytes)
+	}
+	return priorKey, nil
+}
+
+func (h *appHandler) handlePost(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	m := TemplateMap{}
+	err := r.ParseForm()
+	if err != nil {
+		defer h.renderError(m, "Invalid request", w)
+		return
+	}
+
+	aadb := database.New(h.env.Database())
+
+	if action := r.FormValue("TODO"); action == "save" {
+		priorKey, err := decodePriorKey(r.FormValue("Key"))
+		if err != nil {
+			defer h.renderError(m, "Invalid request", w)
+			return
+		}
+
+		// Create new, or load previous.
+		authApp := model.NewAuthorizedApp()
+		if priorKey != "" {
+			authApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey)
+			if err != nil {
+				defer h.renderError(m, "Invalid request, app to edit not found.", w)
+				return
+			}
+		}
+		errors := populateAuthorizedApp(authApp, r)
+		if len(errors) > 0 {
+			m.AddErrors(errors...)
+			m["app"] = authApp
+			h.config.RenderTemplate(w, "app_view", m)
+			return
+		}
+
+		errors = authApp.Validate()
+		if len(errors) > 0 {
+			m.AddErrors(errors...)
+			m["app"] = authApp
+			h.config.RenderTemplate(w, "app_view", m)
+			return
+		}
+
+		if priorKey != "" {
+			if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
+				m.AddErrors(fmt.Sprintf("Error removing old version: %v", err))
+				m["app"] = authApp
+				h.config.RenderTemplate(w, "app_view", m)
+				return
+			}
+		}
+
+		if err := aadb.InsertAuthorizedApp(ctx, authApp); err != nil {
+			m.AddErrors(fmt.Sprintf("Error inserting authorized app: %v", err))
+		} else {
+			m.AddSuccess(fmt.Sprintf("Saved authorized app: %v", authApp.AppPackageName))
+		}
+		m["app"] = authApp
+		h.config.RenderTemplate(w, "app_view", m)
+		return
+
+	} else if action == "delete" {
+		priorKey, err := decodePriorKey(r.FormValue("Key"))
+		if err != nil {
+			defer h.renderError(m, "Invalid request", w)
+			return
+		}
+
+		_, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), priorKey)
+		if err != nil {
+			defer h.renderError(m, "Couldn't find record to delete.", w)
+			return
+		}
+
+		if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
+			defer h.renderError(m, fmt.Sprintf("Error deleting authorized app: %v", err), w)
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey))
+		m["app"] = model.NewAuthorizedApp()
+		h.config.RenderTemplate(w, "app_view", m)
+		return
+	}
+
+	h.renderError(m, "Invalid action requested", w)
+}
+
+func (h *appHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	m := TemplateMap{}
+
+	appIds := r.URL.Query()["apn"]
+	appID := ""
+	if len(appIds) > 0 {
+		appID = appIds[0]
+	}
+
+	authorizedApp := model.NewAuthorizedApp()
+
+	if appID == "" {
+		m.AddJumbotron("Authorized Applications", "Create New Authorized Application")
+		m["new"] = true
+	} else {
+		aadb := database.New(h.env.Database())
+		var err error
+		authorizedApp, err = aadb.GetAuthorizedApp(ctx, h.env.SecretManager(), appID)
+		if err != nil {
+			m["error"] = err
+			h.config.RenderTemplate(w, "error", m)
+			return
+		}
+		m.AddJumbotron("Authorized Applications", fmt.Sprintf("Edit: `%v`", authorizedApp.AppPackageName))
+	}
+	m["app"] = authorizedApp
+	m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName))
+	h.config.RenderTemplate(w, "app_view", m)
+}
diff --git a/tools/admin-console/config.go b/tools/admin-console/config.go
new file mode 100644
index 0000000..3dc778c
--- /dev/null
+++ b/tools/admin-console/config.go
@@ -0,0 +1,67 @@
+// 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.
+
+// This tool provides a small admin UI. Requires connection to the database
+// and permissions to access whatever else you might need to access.
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/setup"
+)
+
+var _ setup.DBConfigProvider = (*Config)(nil)
+
+type Config struct {
+	Port         string `envconfig:"PORT" default:"8080"`
+	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"tools/admin-console/templates"`
+	Database     *database.Config
+	TopFile      string `envconfig:"TOP_FILE" default:"top"`
+	BotFile      string `envconfig:"BOTTOM_FILE" default:"bottom"`
+}
+
+// DB returns the configuration for the databse.
+func (c *Config) DB() *database.Config {
+	return c.Database
+}
+
+func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMap) error {
+	files := []string{
+		fmt.Sprintf("%s/%s.html", c.TemplatePath, c.TopFile),
+		fmt.Sprintf("%s/%s.html", c.TemplatePath, tmpl),
+		fmt.Sprintf("%s/%s.html", c.TemplatePath, c.BotFile),
+	}
+
+	t, err := template.ParseFiles(files...)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(err.Error()))
+		log.Printf("ERROR: %v", err)
+		return err
+	}
+	if err := t.ExecuteTemplate(w, "view", p); err != nil {
+		message := fmt.Sprintf("error rendering template: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(message))
+		log.Printf("ERROR: %v", err)
+		return fmt.Errorf("error rendering template: %w", err)
+	}
+
+	return nil
+}
diff --git a/tools/admin-console/index.go b/tools/admin-console/index.go
new file mode 100644
index 0000000..5ec4c2b
--- /dev/null
+++ b/tools/admin-console/index.go
@@ -0,0 +1,62 @@
+// 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, softwar
+// 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 main
+
+import (
+	"net/http"
+
+	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	exdb "github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type indexHandler struct {
+	config *Config
+	env    *serverenv.ServerEnv
+}
+
+func NewIndexHandler(c *Config, env *serverenv.ServerEnv) *indexHandler {
+	return &indexHandler{config: c, env: env}
+}
+
+func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	m := TemplateMap{}
+
+	// Load authorized apps for index.
+	db := aadb.New(h.env.Database())
+	apps, err := db.GetAllAuthorizedApps(ctx, h.env.SecretManager())
+	if err != nil {
+		m["error"] = err
+		h.config.RenderTemplate(w, "error", m)
+		return
+	}
+	m["apps"] = apps
+
+	// Load export configurations.
+
+	exportDB := exdb.New(h.env.Database())
+	exports, err := exportDB.GetAllExportConfigs(ctx)
+	if err != nil {
+		m["error"] = err
+		h.config.RenderTemplate(w, "error", m)
+		return
+	}
+	m["exports"] = exports
+
+	m.AddTitle("Exposure Notifications Server - Admin Console")
+	m.AddJumbotron("Exposure Notification Server", "Admin Console")
+	h.config.RenderTemplate(w, "index", m)
+}
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
new file mode 100644
index 0000000..b9cdc7a
--- /dev/null
+++ b/tools/admin-console/main.go
@@ -0,0 +1,42 @@
+// 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.
+
+// This tool provides a small admin UI. Requires connection to the database
+// and permissions to access whatever else you might need to access.
+package main
+
+import (
+	"context"
+	"log"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/setup"
+)
+
+func main() {
+	ctx := context.Background()
+
+	var config Config
+	env, closer, err := setup.Setup(ctx, &config)
+	if err != nil {
+		log.Fatalf("setup.Setup: %v", err)
+	}
+	defer closer()
+
+	http.Handle("/", NewIndexHandler(&config, env))
+	http.Handle("/app", NewAppHandler(&config, env))
+
+	log.Printf("listening on http://localhost:" + config.Port)
+	log.Fatal(http.ListenAndServe(":"+config.Port, nil))
+}
diff --git a/tools/admin-console/templates/app_view.html b/tools/admin-console/templates/app_view.html
new file mode 100644
index 0000000..e0b624f
--- /dev/null
+++ b/tools/admin-console/templates/app_view.html
@@ -0,0 +1,128 @@
+{{define "view"}}
+{{template "top" .}}
+
+{{if .new}}
+<h2>Create New Authorized Application</h2>
+{{else}}
+<h2>Edit Application {{.app.AppPackageName}}</h2>
+{{end}}
+
+<form method="POST" action="/app?&apn={{.app.AppPackageName}}" class="form-horizontal">
+  <input type="hidden" name="Key" value="{{.previousKey}}" />
+  <div class="alert alert-info" role="alert">Basic Application Authorization Information</div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="AppPackageName">App Package Name / Bundle ID:</label>
+    <div class="col-sm-6">
+      <input type="text" id="AppPackageName" name="AppPackageName" size="50" value="{{.app.AppPackageName}}">
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="Platform">Platform:</label>
+    <div class="col-sm-6">
+    <select name="Platform" id="Platform" value="{{.app.Platform}}">
+      <option value="android">Android</option>
+      <option value="ios">iOS</option>
+      <option value="both">both</option>
+    </select>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="Regions">Regions:</label>
+    <div class="col-sm-6">
+      <textarea class="form-control" name="Regions" id="Regions" rows="3">{{.app.RegionsOnePerLine}}</textarea>
+      <small id="RegionsHelpBlock" class="form-text text-muted">One per line, leave blank for all</small>
+    </div>
+  </div>
+  <hr/>
+
+  <div class="alert alert-info" role="alert">For Android Applications, these settings define if SafetyNet device attestation is in use and how it should be enforced.</div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetDisabled">SafetyNet Verification Disabled:</label>
+    <div class="col-sm-6">
+      <select name="SafetyNetDisabled" id="SafetyNetDisabled" value="{{.app.SafetyNetDisabled}}">
+        <option value="false">false</option>
+        <option value="true">true</option>
+      </select>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetApkDigestSHA256">Android APK Digest SHA256:</label>
+    <div class="col-sm-6">
+      <textarea class="form-control" name="SafetyNetApkDigestSHA256" id="SafetyNetApkDigestSHA256" rows="3">{{.app.APKDigestOnePerLine}}</textarea>
+      <small id="RegionsHelpBlock" class="form-text text-muted">One per line. Normally there is one, but could be more than one if there are test/release builds.</small>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetBasicIntegrity">Require Basic Integrity:</label>
+    <div class="col-sm-6">
+      <select name="SafetyNetBasicIntegrity" id="SafetyNetBasicIntegrity" value="{{.app.SafetyNetBasicIntegrity}}">
+        <option value="true">true</option>
+        <option value="false">false</option>
+      </select>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetCTSProfileMatch">Require CTS Profile Match:</label>
+    <div class="col-sm-6">
+      <select name="SafetyNetCTSProfileMatch" id="SafetyNetCTSProfileMatch" value="{{.app.SafetyNetCTSProfileMatch}}">
+        <option value="true">true</option>
+        <option value="false">false</option>
+      </select>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetPastTime">Attestation Max Age:</label>
+    <div class="col-sm-6">
+      <input type="text" id="SafetyNetPastTime" name="SafetyNetPastTime" size="10" value="{{.app.SafetyNetPastTime}}">
+    </div>
+    <small id="SafetyNetPastTimeHelpBlock" class="form-text text-muted">Valid time units are "s", "m", "h". This value should be small (30-60 seconds max)</small>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="SafetyNetFutureTime">Attestation Future Time:</label>
+    <div class="col-sm-6">
+      <input type="text" id="SafetyNetFutureTime" name="SafetyNetFutureTime" size="10" value="{{.app.SafetyNetFutureTime}}">
+    </div>
+    <small id="SafetyNetFutureTimeHelpBlock" class="form-text text-muted">If Attestations may have a time newer than this server's time. Normally this value will small, perhaps `1s`.</small>
+  </div>
+  <hr/>
+
+
+  <div class="alert alert-info" role="alert">For iOS applications, should DeviceCheck be used and how should it be configured.</div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="DeviceCheckDisabled">DeviceCheck Verification Disabled:</label>
+    <div class="col-sm-6">
+      <select name="DeviceCheckDisabled" id="DeviceCheckDisabled" value="{{.app.DeviceCheckDisabled}}">
+        <option value="false">false</option>
+        <option value="true">true</option>
+      </select>
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="DeviceCheckKeyID">DeviceCheck Key ID:</label>
+    <div class="col-sm-6">
+      <input type="text" id="DeviceCheckKeyID" name="DeviceCheckKeyID" size="40" value="{{.app.DeviceCheckKeyID}}">
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="DeviceCheckTeamID">DeviceCheck Team ID:</label>
+    <div class="col-sm-6">
+      <input type="text" id="DeviceCheckTeamID" name="DeviceCheckTeamID" size="40" value="{{.app.DeviceCheckTeamID}}">
+    </div>
+  </div>
+  <div class="form-group row">
+    <label class="control-label col-sm-3" for="DeviceCheckPrivateKeySecret">DeviceCheck Private Key Secret:</label>
+    <div class="col-sm-6">
+      <input type="text" id="DeviceCheckPrivateKeySecret" name="DeviceCheckPrivateKeySecret" size="80" value="{{.app.DeviceCheckPrivateKeySecret}}">
+    </div>
+    <small id="DeviceCheckPrivateKeySecretHelpBlock" class="form-text text-muted">The actual private key must be stored in your Secret Manager, this is the resource ID for the secret.</small>
+  </div>
+
+  <hr/>
+  <button type="submit" class="btn btn-outline-primary" name="TODO" value="save">Save Changes</button>
+  <a href="/" class="btn btn-outline-secondary">Cancel</a>
+  <button type="submit" class="btn btn-outline-danger" name="TODO" value="delete">Delete</button>
+
+</form>
+
+{{template "bottom" .}}
+{{end}}
diff --git a/tools/admin-console/templates/bottom.html b/tools/admin-console/templates/bottom.html
new file mode 100644
index 0000000..56cad6b
--- /dev/null
+++ b/tools/admin-console/templates/bottom.html
@@ -0,0 +1,5 @@
+{{define "bottom"}}
+</div>
+</body>
+</html>
+{{end}}
diff --git a/tools/admin-console/templates/error.html b/tools/admin-console/templates/error.html
new file mode 100644
index 0000000..9a8e8a9
--- /dev/null
+++ b/tools/admin-console/templates/error.html
@@ -0,0 +1,1 @@
+<!-- this space left intentionally blank -->
diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html
new file mode 100644
index 0000000..bc5eaa0
--- /dev/null
+++ b/tools/admin-console/templates/index.html
@@ -0,0 +1,30 @@
+{{define "view"}}
+{{template "top" .}}
+
+<h2>Authorized Applications</h2>
+<ul>
+  {{if .apps}}
+    {{range .apps}}
+      <li><a href="/app?apn={{.AppPackageName}}">{{.AppPackageName}}</a> | {{.Platform}}</li>
+    {{end}}
+  {{else}}
+    <li><i>There are no authorized applications</i></li>
+  {{end}}
+</ul>
+<a href="/app?apn="  class="btn btn-outline-primary">Create new Authorized Application</a>
+
+<hr/>
+<h2>Export Configurations</h2>
+<ul>
+ <li>Create New <a href="/export?id=-1">Export Config</a> | <a href="/signatureinfo?id=-1">Signature Info</a></li>
+ {{if .exports}}
+   {{range .exports}}
+     <li><a href="/export?id={{.ConfigID}}">{{.ConfigID}}</a> Region: {{.Region}} | Bucket: {{.BucketName}} | From: {{.FormattedFromTime}} | Thru: {{.FormattedThruTime}}</li>
+   {{end}}
+ {{else}}
+   <li><i>There are no export configurations</i></li>
+ {{end}}
+</ul>
+
+{{template "bottom" .}}
+{{end}}
diff --git a/tools/admin-console/templates/top.html b/tools/admin-console/templates/top.html
new file mode 100644
index 0000000..03f031a
--- /dev/null
+++ b/tools/admin-console/templates/top.html
@@ -0,0 +1,50 @@
+{{define "top"}}
+<html>
+<head>
+  <title>{{if .title}} {{.title}} {{else}} Exposure Notifications {{end}}</title>
+  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
+</head>
+
+<body>
+  <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+   <a class="navbar-brand" href="#">Exposure Notification Server</a>
+   <div class="collapse navbar-collapse" id="navbarText">
+      <ul class="navbar-nav mr-auto">
+        <li class="nav-item">
+          <a class="nav-link" href="/">Home</a>
+        </li>
+      </ul>
+    </div>
+  </nav>
+<div class="container">
+
+{{if .jumbotron}}
+<div class="jumbotron">
+  <h1 class="display-4">{{.jumbotron}}</h1>
+	{{if .jumbotronsub}}
+  <h2 class="display-4">{{.jumbotronsub}}</h2>
+	{{end}}
+  <hr class="my-4">
+</div>
+{{end}}
+
+{{if .error}}
+<div class="alert alert-danger" role="alert">
+  <ul>
+  {{range .error}}
+    <li>{{.}}</li>
+  {{end}}
+  </ul>
+</div>
+{{end}}
+{{if .success}}
+<div class="alert alert-success" role="alert">
+  <ul>
+  {{range .success}}
+    <li>{{.}}</li>
+  {{end}}
+  </ul>
+</div>
+{{end}}
+
+{{end}}
diff --git a/tools/admin-console/util.go b/tools/admin-console/util.go
new file mode 100644
index 0000000..98ec6e0
--- /dev/null
+++ b/tools/admin-console/util.go
@@ -0,0 +1,40 @@
+// 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, softwar
+// 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 main
+
+type TemplateMap map[string]interface{}
+
+func (t TemplateMap) AddTitle(title string) {
+	t["title"] = title
+}
+
+func (t TemplateMap) AddJumbotron(headline, subheader string) {
+	t["jumbotron"] = headline
+	if subheader != "" {
+		t["jumbotronsub"] = subheader
+	}
+}
+
+func (t TemplateMap) AddSubNav(name string) {
+	t["subnav"] = name
+}
+
+func (t TemplateMap) AddErrors(errors ...string) {
+	t["error"] = errors
+}
+
+func (t TemplateMap) AddSuccess(success ...string) {
+	t["success"] = success
+}


Test patch:
diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go
index 2e55520..c7670cb 100644
--- a/internal/authorizedapp/database/authorized_app_test.go
+++ b/internal/authorizedapp/database/authorized_app_test.go
@@ -60,6 +60,47 @@ func TestMain(m *testing.M) {
 	os.Exit(m.Run())
 }
 
+func TestAuthorizedAppLifecycle(t *testing.T) {
+	if testDB == nil {
+		t.Skip("no test DB")
+	}
+	defer coredb.ResetTestDB(t, testDB)
+	ctx := context.Background()
+	aadb := New(testDB)
+	sm := &testSecretManager{
+		values: map[string]string{},
+	}
+
+	source := &model.AuthorizedApp{
+		AppPackageName:      "myapp",
+		Platform:            "both",
+		AllowedRegions:      map[string]struct{}{"US": {}},
+		SafetyNetDisabled:   true,
+		DeviceCheckDisabled: true,
+	}
+
+	if err := aadb.InsertAuthorizedApp(ctx, source); err != nil {
+		t.Fatal(err)
+	}
+
+	readBack, err := aadb.GetAuthorizedApp(ctx, sm, source.AppPackageName)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if diff := cmp.Diff(source, readBack); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	if err := aadb.DeleteAuthorizedApp(ctx, source.AppPackageName); err != nil {
+		t.Fatal(err)
+	}
+
+	readBack, err = aadb.GetAuthorizedApp(ctx, sm, source.AppPackageName)
+	if readBack != nil {
+		t.Fatal("expected record to be deleted, but it wasn't")
+	}
+}
+
 func TestGetAuthorizedApp(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
@@ -100,7 +141,6 @@ func TestGetAuthorizedApp(t *testing.T) {
 				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
 				VALUES ($1, $2, $3)
 			`,
-
 			args: []interface{}{"myapp", "android", []string{"US"}},
 			exp: &model.AuthorizedApp{
 				AppPackageName:           "myapp",
@@ -204,15 +244,16 @@ func TestGetAuthorizedApp(t *testing.T) {
 				false, "ABCD1234", "DEFG5678", "private_key",
 			},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "ios",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetCTSProfileMatch: true,
-				SafetyNetBasicIntegrity:  true,
-				DeviceCheckDisabled:      false,
-				DeviceCheckTeamID:        "ABCD1234",
-				DeviceCheckKeyID:         "DEFG5678",
-				DeviceCheckPrivateKey:    p8PrivateKey,
+				AppPackageName:              "myapp",
+				Platform:                    "ios",
+				AllowedRegions:              map[string]struct{}{"US": {}},
+				SafetyNetCTSProfileMatch:    true,
+				SafetyNetBasicIntegrity:     true,
+				DeviceCheckDisabled:         false,
+				DeviceCheckTeamID:           "ABCD1234",
+				DeviceCheckKeyID:            "DEFG5678",
+				DeviceCheckPrivateKey:       p8PrivateKey,
+				DeviceCheckPrivateKeySecret: "private_key",
 			},
 		},
 		{
diff --git a/tools/admin-console/authorizedapps_test.go b/tools/admin-console/authorizedapps_test.go
new file mode 100644
index 0000000..872943d
--- /dev/null
+++ b/tools/admin-console/authorizedapps_test.go
@@ -0,0 +1,41 @@
+// 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, softwar
+// 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 main
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+)
+
+func TestRenderAuthorizedApps(t *testing.T) {
+	// Hello developer!
+	// If this test fails, it's likely that you changed something in
+	//  internal/authorizedapp/model/
+	// And whatever you changed is used in the
+	//  tools/admin-console/templates/app_view.html
+	// That is what caused the test failure.
+	m := TemplateMap{}
+	authorizedApp := model.NewAuthorizedApp()
+	m["app"] = authorizedApp
+
+	recorder := httptest.NewRecorder()
+	config := Config{TemplatePath: "templates", TopFile: "top", BotFile: "bottom"}
+	err := config.RenderTemplate(recorder, "app_view", m)
+	if err != nil {
+		t.Fatalf("error rendering template: %v", err)
+	}
+}