Repository: google/exposure-notifications-server
Pull request #1275 (2021-01-08 23:01:37)

Make admin-console deployable
/assign @mikehelmick 

Part of https://github.com/google/exposure-notifications-server/issues/1273

**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Make admin-console deployable
```

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1275#" 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/main/OWNERS)~~ [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: /assign @mikehelmick 

Patch:
diff --git a/builders/service.dockerfile b/builders/admin-console.dockerfile
similarity index 100%
rename from builders/service.dockerfile
rename to builders/admin-console.dockerfile
diff --git a/builders/build.yaml b/builders/build.yaml
index bb4a6a4..cdbc648 100644
--- a/builders/build.yaml
+++ b/builders/build.yaml
@@ -61,6 +61,32 @@ steps:
   waitFor:
   - 'download-modules'
 
+#
+# admin-console
+#
+- id: 'build-admin-console'
+  name: 'golang:1.15.2'
+  args:
+  - 'go'
+  - 'build'
+  - '-trimpath'
+  - '-ldflags=-s -w -X=${_REPO}/internal/buildinfo.BuildID=${BUILD_ID} -X=${_REPO}/internal/buildinfo.BuildTag=${_TAG} -extldflags=-static'
+  - '-o=./bin/admin-console'
+  - './cmd/admin-console'
+  waitFor:
+  - 'download-modules'
+
+- id: 'dockerize-admin-console'
+  name: 'docker:19'
+  args:
+  - 'build'
+  - '--file=builders/admin-console.dockerfile'
+  - '--tag=gcr.io/${PROJECT_ID}/${_REPO}/admin-console:${_TAG}'
+  - '--build-arg=SERVICE=admin-console'
+  - '.'
+  waitFor:
+  - 'build-admin-console'
+
 #
 # cleanup-export
 #
@@ -399,6 +425,7 @@ steps:
   - 'build-jwks'
 
 images:
+- 'gcr.io/${PROJECT_ID}/${_REPO}/admin-console:${_TAG}'
 - 'gcr.io/${PROJECT_ID}/${_REPO}/cleanup-export:${_TAG}'
 - 'gcr.io/${PROJECT_ID}/${_REPO}/cleanup-exposure:${_TAG}'
 - 'gcr.io/${PROJECT_ID}/${_REPO}/debugger:${_TAG}'
diff --git a/builders/deploy.yaml b/builders/deploy.yaml
index dddfe05..0c3c2f7 100644
--- a/builders/deploy.yaml
+++ b/builders/deploy.yaml
@@ -22,6 +22,27 @@ substitutions:
   _TAG:
 
 steps:
+#
+# admin-console
+#
+- id: 'deploy-admin-console'
+  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:307.0.0-alpine'
+  args:
+  - 'bash'
+  - '-eEuo'
+  - 'pipefail'
+  - '-c'
+  - |-
+    gcloud run deploy "admin-console" \
+      --quiet \
+      --project "${PROJECT_ID}" \
+      --platform "managed" \
+      --region "${_REGION}" \
+      --image "gcr.io/${PROJECT_ID}/${_REPO}/admin-console:${_TAG}" \
+      --no-traffic
+  waitFor:
+  - '-'
+
 #
 # cleanup-exposure
 #
diff --git a/builders/promote.yaml b/builders/promote.yaml
index 087c794..4457ae4 100644
--- a/builders/promote.yaml
+++ b/builders/promote.yaml
@@ -22,6 +22,26 @@ substitutions:
   _REVISION:
 
 steps:
+#
+# admin-console
+#
+- id: 'promote-admin-console'
+  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:307.0.0-alpine'
+  args:
+  - 'bash'
+  - '-eEuo'
+  - 'pipefail'
+  - '-c'
+  - |-
+    gcloud run services update-traffic "admin-console" \
+      --quiet \
+      --project "${PROJECT_ID}" \
+      --platform "managed" \
+      --region "${_REGION}" \
+      --to-revisions "${_REVISION}=${_PERCENTAGE}"
+  waitFor:
+  - '-'
+
 #
 # cleanup-exposure
 #
diff --git a/builders/server.dockerfile b/builders/server.dockerfile
new file mode 100644
index 0000000..9d44ec0
--- /dev/null
+++ b/builders/server.dockerfile
@@ -0,0 +1,31 @@
+# Copyright 2021 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 alpine AS builder
+RUN mkdir -p /var/run/secrets && \
+  chmod 0700 /var/run/secrets && \
+  chown 65534:65534 /var/run/secrets
+
+FROM scratch
+COPY ./builders/passwd /etc/passwd
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+
+USER nobody
+COPY ./bin/admin-console /admin-console
+COPY ./cmd/admin-console/templates /templates
+COPY --from=builder /var/run /var/run
+COPY --from=builder /var/run/secrets /var/run/secrets
+
+ENV PORT 8080
+ENTRYPOINT ["/admin-console"]
diff --git a/cmd/admin-console/main.go b/cmd/admin-console/main.go
new file mode 100644
index 0000000..6c27ed2
--- /dev/null
+++ b/cmd/admin-console/main.go
@@ -0,0 +1,68 @@
+// 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 main runs the debugger service.
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/google/exposure-notifications-server/internal/admin"
+	"github.com/google/exposure-notifications-server/internal/buildinfo"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/server"
+	"github.com/sethvargo/go-signalcontext"
+)
+
+func main() {
+	ctx, done := signalcontext.OnInterrupt()
+
+	logger := logging.NewLoggerFromEnv().
+		With("build_id", buildinfo.BuildID).
+		With("build_tag", buildinfo.BuildTag)
+	ctx = logging.WithLogger(ctx, logger)
+
+	err := realMain(ctx)
+	done()
+
+	if err != nil {
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
+	logger := logging.FromContext(ctx)
+
+	var config admin.Config
+	env, err := setup.Setup(ctx, &config)
+	if err != nil {
+		return fmt.Errorf("setup.Setup: %w", err)
+	}
+	defer env.Close(ctx)
+
+	adminServer, err := admin.NewServer(&config, env)
+	if err != nil {
+		return fmt.Errorf("export.NewServer: %w", err)
+	}
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, adminServer.Routes(ctx))
+}
diff --git a/tools/admin-console/templates/authorizedapp.html b/cmd/admin-console/templates/authorizedapp.html
similarity index 100%
rename from tools/admin-console/templates/authorizedapp.html
rename to cmd/admin-console/templates/authorizedapp.html
diff --git a/tools/admin-console/templates/bottom.html b/cmd/admin-console/templates/bottom.html
similarity index 100%
rename from tools/admin-console/templates/bottom.html
rename to cmd/admin-console/templates/bottom.html
diff --git a/tools/admin-console/templates/error.html b/cmd/admin-console/templates/error.html
similarity index 100%
rename from tools/admin-console/templates/error.html
rename to cmd/admin-console/templates/error.html
diff --git a/tools/admin-console/templates/export-importer.html b/cmd/admin-console/templates/export-importer.html
similarity index 100%
rename from tools/admin-console/templates/export-importer.html
rename to cmd/admin-console/templates/export-importer.html
diff --git a/tools/admin-console/templates/export.html b/cmd/admin-console/templates/export.html
similarity index 100%
rename from tools/admin-console/templates/export.html
rename to cmd/admin-console/templates/export.html
diff --git a/tools/admin-console/templates/healthauthority.html b/cmd/admin-console/templates/healthauthority.html
similarity index 100%
rename from tools/admin-console/templates/healthauthority.html
rename to cmd/admin-console/templates/healthauthority.html
diff --git a/tools/admin-console/templates/index.html b/cmd/admin-console/templates/index.html
similarity index 100%
rename from tools/admin-console/templates/index.html
rename to cmd/admin-console/templates/index.html
diff --git a/tools/admin-console/templates/mirror.html b/cmd/admin-console/templates/mirror.html
similarity index 100%
rename from tools/admin-console/templates/mirror.html
rename to cmd/admin-console/templates/mirror.html
diff --git a/tools/admin-console/templates/siginfo.html b/cmd/admin-console/templates/siginfo.html
similarity index 100%
rename from tools/admin-console/templates/siginfo.html
rename to cmd/admin-console/templates/siginfo.html
diff --git a/tools/admin-console/templates/top.html b/cmd/admin-console/templates/top.html
similarity index 100%
rename from tools/admin-console/templates/top.html
rename to cmd/admin-console/templates/top.html
diff --git a/go.sum b/go.sum
index c230f36..994ff69 100644
--- a/go.sum
+++ b/go.sum
@@ -1028,6 +1028,7 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-shellwords v1.0.5 h1:JhhFTIOslh5ZsPrpa3Wdg8bF0WI3b44EMblmU9wIsXc=
 github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
diff --git a/internal/admin/authorizedapps/form.go b/internal/admin/authorizedapps/form.go
deleted file mode 100644
index 0faf842..0000000
--- a/internal/admin/authorizedapps/form.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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 authorizedapps is part of the admin system.
-package authorizedapps
-
-import (
-	"encoding/base64"
-	"strings"
-
-	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
-)
-
-type formData struct {
-	// Top Level
-	FormKey string `form:"key"`
-	Action  string `form:"action"`
-
-	// Authorized App Data
-	AppPackageName                    string  `form:"app-package-name"`
-	AllowedRegions                    string  `form:"regions"`
-	BypassHealthAuthorityVerification bool    `form:"bypass-health-authority-verification"`
-	BypassRevisionToken               bool    `form:"bypass-revision-token"`
-	HealthAuthorityIDs                []int64 `form:"health-authorities"`
-}
-
-func (f *formData) PriorKey() string {
-	if f.FormKey != "" {
-		bytes, err := base64.StdEncoding.DecodeString(f.FormKey)
-		if err != nil {
-			return ""
-		}
-		return string(bytes)
-	}
-	return ""
-}
-
-func (f *formData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
-	a.AppPackageName = strings.TrimSpace(f.AppPackageName)
-	a.AllowedRegions = make(map[string]struct{})
-	for _, region := range strings.Split(f.AllowedRegions, "\n") {
-		region = strings.TrimSpace(region)
-		if region != "" {
-			a.AllowedRegions[strings.TrimSpace(region)] = struct{}{}
-		}
-	}
-	a.AllowedHealthAuthorityIDs = make(map[int64]struct{})
-	for _, haID := range f.HealthAuthorityIDs {
-		a.AllowedHealthAuthorityIDs[haID] = struct{}{}
-	}
-	a.BypassHealthAuthorityVerification = f.BypassHealthAuthorityVerification
-	a.BypassRevisionToken = f.BypassRevisionToken
-	return nil
-}
diff --git a/internal/admin/authorizedapps/save.go b/internal/admin/authorizedapps/save.go
deleted file mode 100644
index e25bf1e..0000000
--- a/internal/admin/authorizedapps/save.go
+++ /dev/null
@@ -1,127 +0,0 @@
-// 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 authorizedapps
-
-import (
-	"encoding/base64"
-	"fmt"
-	"log"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"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"
-	verdb "github.com/google/exposure-notifications-server/internal/verification/database"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (h *saveController) Execute(c *gin.Context) {
-	var form formData
-	err := c.Bind(&form)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	aadb := database.New(h.env.Database())
-	if form.Action == "save" {
-		// Create new, or load previous.
-		authApp := model.NewAuthorizedApp()
-		priorKey := form.PriorKey()
-		if priorKey != "" {
-			authApp, err = aadb.GetAuthorizedApp(ctx, priorKey)
-			if err != nil {
-				admin.ErrorPage(c, "Invalid request, app to edit not found.")
-				return
-			}
-		}
-		if err := form.PopulateAuthorizedApp(authApp); err != nil {
-			admin.ErrorPage(c, err.Error())
-			return
-		}
-		errors := authApp.Validate()
-		if len(errors) > 0 {
-			m.AddErrors(errors...)
-			m["app"] = authApp
-			// Load the health authorities into m for display on the edit form.
-			if err := addHealthAuthorityInfo(ctx, verdb.New(h.env.Database()), authApp, m); err != nil {
-				admin.ErrorPage(c, err.Error())
-				return
-			}
-			c.HTML(http.StatusOK, "authorizedapp", m)
-			return
-		}
-
-		if priorKey != "" {
-			if err := aadb.UpdateAuthorizedApp(ctx, priorKey, authApp); err != nil {
-				m.AddErrors(fmt.Sprintf("Error removing old version: %v", err))
-				m["app"] = authApp
-				c.HTML(http.StatusOK, "authorizedapp", m)
-				return
-			}
-			m.AddSuccess(fmt.Sprintf("Updated authorized app: %v", authApp.AppPackageName))
-		} else {
-			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))
-			}
-		}
-
-		if err := addHealthAuthorityInfo(ctx, verdb.New(h.env.Database()), authApp, m); err != nil {
-			admin.ErrorPage(c, err.Error())
-			return
-		}
-
-		m["app"] = authApp
-		m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authApp.AppPackageName))
-		c.HTML(http.StatusOK, "authorizedapp", m)
-		return
-	} else if form.Action == "delete" {
-		priorKey := form.PriorKey()
-
-		log.Printf("Deleting authorized app: %v", priorKey)
-		if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error deleting authorized app: %v", err))
-			return
-		}
-
-		authorizedApp := model.NewAuthorizedApp()
-		if err := addHealthAuthorityInfo(ctx, verdb.New(h.env.Database()), authorizedApp, m); err != nil {
-			admin.ErrorPage(c, err.Error())
-			return
-		}
-
-		m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey))
-		m["app"] = authorizedApp
-		c.HTML(http.StatusOK, "authorizedapp", m)
-		return
-	}
-
-	admin.ErrorPage(c, "Invalid request, app to edit not found.")
-}
diff --git a/internal/admin/authorizedapps/view.go b/internal/admin/authorizedapps/view.go
deleted file mode 100644
index 0d7db79..0000000
--- a/internal/admin/authorizedapps/view.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// 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 authorizedapps
-
-import (
-	"context"
-	"encoding/base64"
-	"fmt"
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"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"
-	verdb "github.com/google/exposure-notifications-server/internal/verification/database"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func addHealthAuthorityInfo(ctx context.Context, haDB *verdb.HealthAuthorityDB, app *model.AuthorizedApp, m admin.TemplateMap) error {
-	// Load the health authorities.
-	healthAuthorities, err := haDB.ListAllHealthAuthoritiesWithoutKeys(ctx)
-	if err != nil {
-		return fmt.Errorf("error loading health authorities: %w", err)
-	}
-
-	usedAuthorities := make(map[int64]bool)
-	for _, k := range app.AllAllowedHealthAuthorityIDs() {
-		usedAuthorities[k] = true
-	}
-
-	m["has"] = healthAuthorities
-	m["usedHealthAuthorities"] = usedAuthorities
-	return nil
-}
-
-func (h *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	appID, _ := c.GetQuery("apn")
-	authorizedApp := model.NewAuthorizedApp()
-
-	if appID == "" {
-		m["new"] = true
-	} else {
-		aadb := database.New(h.env.Database())
-		var err error
-		authorizedApp, err = aadb.GetAuthorizedApp(ctx, appID)
-		if err != nil {
-			admin.ErrorPage(c, err.Error())
-			return
-		}
-	}
-
-	// Load the health authorities.
-	if err := addHealthAuthorityInfo(ctx, verdb.New(h.env.Database()), authorizedApp, m); err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	m["app"] = authorizedApp
-	m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName))
-	c.HTML(http.StatusOK, "authorizedapp", m)
-}
diff --git a/internal/admin/config.go b/internal/admin/config.go
index 2273b24..68e10f5 100644
--- a/internal/admin/config.go
+++ b/internal/admin/config.go
@@ -41,7 +41,7 @@ type Config struct {
 	Storage       storage.Config
 
 	Port         string `env:"PORT, default=8080"`
-	TemplatePath string `env:"TEMPLATE_DIR, default=./tools/admin-console/templates"`
+	TemplatePath string `env:"TEMPLATE_DIR, default=./cmd/admin-console/templates"`
 	TopFile      string `env:"TOP_FILE, default=top"`
 	BotFile      string `env:"BOTTOM_FILE, default=bottom"`
 }
diff --git a/internal/admin/exportimporters/form.go b/internal/admin/exportimporters/form.go
deleted file mode 100644
index deb74e7..0000000
--- a/internal/admin/exportimporters/form.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// 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 exportimporters is part of the admin system.
-package exportimporters
-
-import (
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/exportimport/model"
-)
-
-type formData struct {
-	IndexFile  string `form:"index-file"`
-	ExportRoot string `form:"export-root"`
-	Region     string `form:"region"`
-
-	// FromDate and FromTime are combined into FromTimestamp.
-	FromDate string `form:"from-date"`
-	FromTime string `form:"from-time"`
-
-	// ThruDate and ThruTime are combined into ThruTimestamp.
-	ThruDate string `form:"thru-date"`
-	ThruTime string `form:"thru-time"`
-}
-
-// BuildExportImporterModel populates and mutates the given model with form
-// data. It overwrites any form data that's present.
-func (f *formData) BuildExportImporterModel(c *model.ExportImport) error {
-	from, err := admin.CombineDateAndTime(f.FromDate, f.FromTime)
-	if err != nil {
-		return err
-	}
-	thru, err := admin.CombineDateAndTime(f.ThruDate, f.ThruTime)
-	if err != nil {
-		return err
-	}
-
-	if val := f.IndexFile; val != "" {
-		c.IndexFile = val
-	}
-
-	if val := f.ExportRoot; val != "" {
-		c.ExportRoot = val
-	}
-
-	if val := f.Region; val != "" {
-		c.Region = val
-	}
-
-	if !from.IsZero() {
-		c.From = from
-	}
-
-	if !thru.IsZero() {
-		c.Thru = &thru
-	}
-
-	return nil
-}
diff --git a/internal/admin/exportimporters/save.go b/internal/admin/exportimporters/save.go
deleted file mode 100644
index ff9f14e..0000000
--- a/internal/admin/exportimporters/save.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// 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 exportimporters
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	exportimportdatabase "github.com/google/exposure-notifications-server/internal/exportimport/database"
-	exportimportmodel "github.com/google/exposure-notifications-server/internal/exportimport/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (v *saveController) Execute(c *gin.Context) {
-	var form formData
-	if err := c.Bind(&form); err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	db := exportimportdatabase.New(v.env.Database())
-	model := new(exportimportmodel.ExportImport)
-
-	idRaw := c.Param("id")
-	if idRaw != "" && idRaw != "0" {
-		id, err := strconv.ParseInt(idRaw, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "failed to to parse `id` param.")
-			return
-		}
-
-		model, err = db.GetConfig(ctx, id)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("failed to load export importer config: %s", err))
-			return
-		}
-	}
-
-	if err := form.BuildExportImporterModel(model); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("failed to build export importer config: %s", err))
-		return
-	}
-
-	fn := db.AddConfig
-	if model.ID != 0 {
-		fn = db.UpdateConfig
-	}
-
-	if err := fn(ctx, model); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("failed to write export importer config: %s", err))
-		return
-	}
-
-	m.AddSuccess("Successfully updated export importer config!")
-	m["model"] = model
-	c.HTML(http.StatusOK, "export-importer", m)
-	c.Abort()
-}
diff --git a/internal/admin/exportimporters/view.go b/internal/admin/exportimporters/view.go
deleted file mode 100644
index 30ae6c5..0000000
--- a/internal/admin/exportimporters/view.go
+++ /dev/null
@@ -1,62 +0,0 @@
-// 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 exportimporters
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	exportimportdatabase "github.com/google/exposure-notifications-server/internal/exportimport/database"
-	exportimportmodel "github.com/google/exposure-notifications-server/internal/exportimport/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func (v *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-
-	db := exportimportdatabase.New(v.env.Database())
-	model := new(exportimportmodel.ExportImport)
-
-	if idRaw := c.Param("id"); idRaw != "" && idRaw != "0" {
-		id, err := strconv.ParseInt(idRaw, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Failed to parse `id` param: %s", err))
-			return
-		}
-
-		model, err = db.GetConfig(ctx, id)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Failed to load export importer config: %s", err))
-			return
-		}
-	}
-
-	m := make(admin.TemplateMap)
-	m["model"] = model
-	c.HTML(http.StatusOK, "export-importer", m)
-	c.Abort()
-}
diff --git a/internal/admin/exports/form.go b/internal/admin/exports/form.go
deleted file mode 100644
index cab78fa..0000000
--- a/internal/admin/exports/form.go
+++ /dev/null
@@ -1,97 +0,0 @@
-// 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 exports is part of the admin system.
-package exports
-
-import (
-	"errors"
-	"fmt"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-)
-
-var ErrCannotSetBothTravelers = errors.New("cannot have both 'include travelers', and 'only non-travelers' set")
-
-type formData struct {
-	OutputRegion       string        `form:"output-region"`
-	InputRegions       string        `form:"input-regions"`
-	IncludeTravelers   bool          `form:"include-travelers"`
-	OnlyNonTravelers   bool          `form:"only-non-travelers"`
-	ExcludeRegions     string        `form:"exclude-regions"`
-	BucketName         string        `form:"bucket-name"`
-	FilenameRoot       string        `form:"filename-root"`
-	Period             time.Duration `form:"period"`
-	FromDate           string        `form:"from-date"`
-	FromTime           string        `form:"from-time"`
-	ThruDate           string        `form:"thru-date"`
-	ThruTime           string        `form:"thru-time"`
-	SigInfoIDs         []int64       `form:"sig-info"`
-	MaxRecordsOverride int           `form:"max-records-override"`
-}
-
-// splitRegions turns a string of regions (generally separated by newlines), and
-// breaks them up into an alphabetically sorted slice of strings.
-func splitRegions(regions string) []string {
-	ret := make([]string, 0, 20)
-	for _, s := range strings.Split(regions, "\n") {
-		s := strings.TrimSpace(s)
-		if s != "" {
-			ret = append(ret, s)
-		}
-	}
-	sort.Strings(ret)
-	return ret
-}
-
-func (f *formData) PopulateExportConfig(ec *model.ExportConfig) error {
-	from, err := admin.CombineDateAndTime(f.FromDate, f.FromTime)
-	if err != nil {
-		return err
-	}
-	thru, err := admin.CombineDateAndTime(f.ThruDate, f.ThruTime)
-	if err != nil {
-		return err
-	}
-	if f.IncludeTravelers && f.OnlyNonTravelers {
-		return ErrCannotSetBothTravelers
-	}
-
-	ec.BucketName = strings.TrimSpace(f.BucketName)
-	ec.FilenameRoot = strings.TrimSpace(f.FilenameRoot)
-	ec.Period = f.Period
-	ec.OutputRegion = strings.TrimSpace(f.OutputRegion)
-	ec.InputRegions = splitRegions(f.InputRegions)
-	ec.IncludeTravelers = f.IncludeTravelers
-	ec.OnlyNonTravelers = f.OnlyNonTravelers
-	ec.ExcludeRegions = splitRegions(f.ExcludeRegions)
-	ec.From = from
-	ec.Thru = thru
-	ec.SignatureInfoIDs = f.SigInfoIDs
-	if f.MaxRecordsOverride > 0 {
-		ec.MaxRecordsOverride = &f.MaxRecordsOverride
-	} else {
-		ec.MaxRecordsOverride = nil
-	}
-
-	if len(ec.SignatureInfoIDs) > 10 {
-		return fmt.Errorf("too many signing keys selected, there is a limit of 10")
-	}
-
-	return nil
-}
diff --git a/internal/admin/exports/save.go b/internal/admin/exports/save.go
deleted file mode 100644
index b555160..0000000
--- a/internal/admin/exports/save.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// 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 exports
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (v *saveController) Execute(c *gin.Context) {
-	var form formData
-	err := c.Bind(&form)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	exportDB := database.New(v.env.Database())
-	exportConfig := &model.ExportConfig{}
-	idParam := c.Param("id")
-	if idParam != "0" {
-		cfgID, err := strconv.ParseInt(idParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "unable to parse `id` param.")
-			return
-		}
-		exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
-			return
-		}
-	}
-
-	if err := form.PopulateExportConfig(exportConfig); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("error processing export config: %v", err))
-	}
-
-	updateFn := exportDB.AddExportConfig
-	if exportConfig.ConfigID != 0 {
-		updateFn = exportDB.UpdateExportConfig
-	}
-	if err := updateFn(ctx, exportConfig); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("Error writing export config: %v", err))
-		return
-	}
-
-	m.AddSuccess(fmt.Sprintf("Updated export config #%v", exportConfig.ConfigID))
-
-	usedSigInfos := make(map[int64]bool)
-	for _, id := range exportConfig.SignatureInfoIDs {
-		usedSigInfos[id] = true
-	}
-
-	sigInfos, err := exportDB.ListAllSignatureInfos(ctx)
-	if err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
-	}
-
-	m["export"] = exportConfig
-	m["usedSigInfos"] = usedSigInfos
-	m["siginfos"] = sigInfos
-	c.HTML(http.StatusOK, "export", m)
-}
diff --git a/internal/admin/exports/view.go b/internal/admin/exports/view.go
deleted file mode 100644
index 68186c4..0000000
--- a/internal/admin/exports/view.go
+++ /dev/null
@@ -1,76 +0,0 @@
-// 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 exports
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func (v *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	exportDB := database.New(v.env.Database())
-	exportConfig := &model.ExportConfig{}
-	if idParam := c.Param("id"); idParam == "0" {
-		// Default the period to suggest an appropriate value.
-		exportConfig.Period = 24 * time.Hour
-		exportConfig.IncludeTravelers = true // Encourage this default.
-	} else {
-		cfgID, err := strconv.ParseInt(idParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "unable to parse `id` param.")
-			return
-		}
-		exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
-			return
-		}
-	}
-
-	usedSigInfos := make(map[int64]bool)
-	for _, id := range exportConfig.SignatureInfoIDs {
-		usedSigInfos[id] = true
-	}
-
-	sigInfos, err := exportDB.ListAllSignatureInfos(ctx)
-	if err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
-	}
-
-	m["export"] = exportConfig
-	m["usedSigInfos"] = usedSigInfos
-	m["siginfos"] = sigInfos
-	c.HTML(http.StatusOK, "export", m)
-}
diff --git a/internal/admin/handle_authorized_apps.go b/internal/admin/handle_authorized_apps.go
new file mode 100644
index 0000000..035359d
--- /dev/null
+++ b/internal/admin/handle_authorized_apps.go
@@ -0,0 +1,215 @@
+// Copyright 2021 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 admin
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	verdb "github.com/google/exposure-notifications-server/internal/verification/database"
+)
+
+// HandleAuthorizedAppsSave handles the create/update actions for authorized
+// apps.
+func (s *Server) HandleAuthorizedAppsSave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form authorizedAppFormData
+		err := c.Bind(&form)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		aadb := database.New(s.env.Database())
+		if form.Action == "save" {
+			// Create new, or load previous.
+			authApp := model.NewAuthorizedApp()
+			priorKey := form.PriorKey()
+			if priorKey != "" {
+				authApp, err = aadb.GetAuthorizedApp(ctx, priorKey)
+				if err != nil {
+					ErrorPage(c, "Invalid request, app to edit not found.")
+					return
+				}
+			}
+			if err := form.PopulateAuthorizedApp(authApp); err != nil {
+				ErrorPage(c, err.Error())
+				return
+			}
+			errors := authApp.Validate()
+			if len(errors) > 0 {
+				m.AddErrors(errors...)
+				m["app"] = authApp
+				// Load the health authorities into m for display on the edit form.
+				if err := addHealthAuthorityInfo(ctx, verdb.New(s.env.Database()), authApp, m); err != nil {
+					ErrorPage(c, err.Error())
+					return
+				}
+				c.HTML(http.StatusOK, "authorizedapp", m)
+				return
+			}
+
+			if priorKey != "" {
+				if err := aadb.UpdateAuthorizedApp(ctx, priorKey, authApp); err != nil {
+					m.AddErrors(fmt.Sprintf("Error removing old version: %v", err))
+					m["app"] = authApp
+					c.HTML(http.StatusOK, "authorizedapp", m)
+					return
+				}
+				m.AddSuccess(fmt.Sprintf("Updated authorized app: %v", authApp.AppPackageName))
+			} else {
+				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))
+				}
+			}
+
+			if err := addHealthAuthorityInfo(ctx, verdb.New(s.env.Database()), authApp, m); err != nil {
+				ErrorPage(c, err.Error())
+				return
+			}
+
+			m["app"] = authApp
+			m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authApp.AppPackageName))
+			c.HTML(http.StatusOK, "authorizedapp", m)
+			return
+		} else if form.Action == "delete" {
+			priorKey := form.PriorKey()
+
+			log.Printf("Deleting authorized app: %v", priorKey)
+			if err := aadb.DeleteAuthorizedApp(ctx, priorKey); err != nil {
+				ErrorPage(c, fmt.Sprintf("Error deleting authorized app: %v", err))
+				return
+			}
+
+			authorizedApp := model.NewAuthorizedApp()
+			if err := addHealthAuthorityInfo(ctx, verdb.New(s.env.Database()), authorizedApp, m); err != nil {
+				ErrorPage(c, err.Error())
+				return
+			}
+
+			m.AddSuccess(fmt.Sprintf("Successfully deleted app `%v`", priorKey))
+			m["app"] = authorizedApp
+			c.HTML(http.StatusOK, "authorizedapp", m)
+			return
+		}
+
+		ErrorPage(c, "Invalid request, app to edit not found.")
+	}
+}
+
+// HandleAuthorizedAppsShow handles the show page for authorized apps.
+func (s *Server) HandleAuthorizedAppsShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		appID, _ := c.GetQuery("apn")
+		authorizedApp := model.NewAuthorizedApp()
+
+		if appID == "" {
+			m["new"] = true
+		} else {
+			aadb := database.New(s.env.Database())
+			var err error
+			authorizedApp, err = aadb.GetAuthorizedApp(ctx, appID)
+			if err != nil {
+				ErrorPage(c, err.Error())
+				return
+			}
+		}
+
+		// Load the health authorities.
+		if err := addHealthAuthorityInfo(ctx, verdb.New(s.env.Database()), authorizedApp, m); err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		m["app"] = authorizedApp
+		m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authorizedApp.AppPackageName))
+		c.HTML(http.StatusOK, "authorizedapp", m)
+	}
+}
+
+// addHealthAuthorityInfo is a helper that adds HA info to the template map.
+func addHealthAuthorityInfo(ctx context.Context, haDB *verdb.HealthAuthorityDB, app *model.AuthorizedApp, m TemplateMap) error {
+	// Load the health authorities.
+	healthAuthorities, err := haDB.ListAllHealthAuthoritiesWithoutKeys(ctx)
+	if err != nil {
+		return fmt.Errorf("error loading health authorities: %w", err)
+	}
+
+	usedAuthorities := make(map[int64]bool)
+	for _, k := range app.AllAllowedHealthAuthorityIDs() {
+		usedAuthorities[k] = true
+	}
+
+	m["has"] = healthAuthorities
+	m["usedHealthAuthorities"] = usedAuthorities
+	return nil
+}
+
+type authorizedAppFormData struct {
+	// Top Level
+	FormKey string `form:"key"`
+	Action  string `form:"action"`
+
+	// Authorized App Data
+	AppPackageName                    string  `form:"app-package-name"`
+	AllowedRegions                    string  `form:"regions"`
+	BypassHealthAuthorityVerification bool    `form:"bypass-health-authority-verification"`
+	BypassRevisionToken               bool    `form:"bypass-revision-token"`
+	HealthAuthorityIDs                []int64 `form:"health-authorities"`
+}
+
+func (f *authorizedAppFormData) PriorKey() string {
+	if f.FormKey != "" {
+		bytes, err := base64.StdEncoding.DecodeString(f.FormKey)
+		if err != nil {
+			return ""
+		}
+		return string(bytes)
+	}
+	return ""
+}
+
+func (f *authorizedAppFormData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
+	a.AppPackageName = strings.TrimSpace(f.AppPackageName)
+	a.AllowedRegions = make(map[string]struct{})
+	for _, region := range strings.Split(f.AllowedRegions, "\n") {
+		region = strings.TrimSpace(region)
+		if region != "" {
+			a.AllowedRegions[strings.TrimSpace(region)] = struct{}{}
+		}
+	}
+	a.AllowedHealthAuthorityIDs = make(map[int64]struct{})
+	for _, haID := range f.HealthAuthorityIDs {
+		a.AllowedHealthAuthorityIDs[haID] = struct{}{}
+	}
+	a.BypassHealthAuthorityVerification = f.BypassHealthAuthorityVerification
+	a.BypassRevisionToken = f.BypassRevisionToken
+	return nil
+}
diff --git a/internal/admin/handle_export_importers.go b/internal/admin/handle_export_importers.go
new file mode 100644
index 0000000..d175a42
--- /dev/null
+++ b/internal/admin/handle_export_importers.go
@@ -0,0 +1,157 @@
+// Copyright 2021 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 admin
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/exportimport/database"
+	"github.com/google/exposure-notifications-server/internal/exportimport/model"
+)
+
+// HandleExportImportersSave handles the create/update actions for export
+// importers.
+func (s *Server) HandleExportImportersSave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form exportImporterFormData
+		if err := c.Bind(&form); err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		db := database.New(s.env.Database())
+		model := new(model.ExportImport)
+
+		idRaw := c.Param("id")
+		if idRaw != "" && idRaw != "0" {
+			id, err := strconv.ParseInt(idRaw, 10, 64)
+			if err != nil {
+				ErrorPage(c, "failed to to parse `id` param.")
+				return
+			}
+
+			model, err = db.GetConfig(ctx, id)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("failed to load export importer config: %s", err))
+				return
+			}
+		}
+
+		if err := form.BuildExportImporterModel(model); err != nil {
+			ErrorPage(c, fmt.Sprintf("failed to build export importer config: %s", err))
+			return
+		}
+
+		fn := db.AddConfig
+		if model.ID != 0 {
+			fn = db.UpdateConfig
+		}
+
+		if err := fn(ctx, model); err != nil {
+			ErrorPage(c, fmt.Sprintf("failed to write export importer config: %s", err))
+			return
+		}
+
+		m.AddSuccess("Successfully updated export importer config!")
+		m["model"] = model
+		c.HTML(http.StatusOK, "export-importer", m)
+		c.Abort()
+	}
+}
+
+// HandleExportImportersShow handles the create/update actions for export
+// importers.
+func (s *Server) HandleExportImportersShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+
+		db := database.New(s.env.Database())
+		model := new(model.ExportImport)
+
+		if idRaw := c.Param("id"); idRaw != "" && idRaw != "0" {
+			id, err := strconv.ParseInt(idRaw, 10, 64)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Failed to parse `id` param: %s", err))
+				return
+			}
+
+			model, err = db.GetConfig(ctx, id)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Failed to load export importer config: %s", err))
+				return
+			}
+		}
+
+		m := make(TemplateMap)
+		m["model"] = model
+		c.HTML(http.StatusOK, "export-importer", m)
+		c.Abort()
+	}
+}
+
+type exportImporterFormData struct {
+	IndexFile  string `form:"index-file"`
+	ExportRoot string `form:"export-root"`
+	Region     string `form:"region"`
+
+	// FromDate and FromTime are combined into FromTimestamp.
+	FromDate string `form:"from-date"`
+	FromTime string `form:"from-time"`
+
+	// ThruDate and ThruTime are combined into ThruTimestamp.
+	ThruDate string `form:"thru-date"`
+	ThruTime string `form:"thru-time"`
+}
+
+// BuildExportImporterModel populates and mutates the given model with form
+// data. It overwrites any form data that's present.
+func (f *exportImporterFormData) BuildExportImporterModel(c *model.ExportImport) error {
+	from, err := CombineDateAndTime(f.FromDate, f.FromTime)
+	if err != nil {
+		return err
+	}
+	thru, err := CombineDateAndTime(f.ThruDate, f.ThruTime)
+	if err != nil {
+		return err
+	}
+
+	if val := f.IndexFile; val != "" {
+		c.IndexFile = val
+	}
+
+	if val := f.ExportRoot; val != "" {
+		c.ExportRoot = val
+	}
+
+	if val := f.Region; val != "" {
+		c.Region = val
+	}
+
+	if !from.IsZero() {
+		c.From = from
+	}
+
+	if !thru.IsZero() {
+		c.Thru = &thru
+	}
+
+	return nil
+}
diff --git a/internal/admin/handle_exports.go b/internal/admin/handle_exports.go
new file mode 100644
index 0000000..cb5dd71
--- /dev/null
+++ b/internal/admin/handle_exports.go
@@ -0,0 +1,200 @@
+// Copyright 2021 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 admin
+
+import (
+	"fmt"
+	"net/http"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+)
+
+// HandleExportsSave handles the create/update actions for exports.
+func (s *Server) HandleExportsSave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form exportFormData
+		err := c.Bind(&form)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		exportDB := database.New(s.env.Database())
+		exportConfig := &model.ExportConfig{}
+		idParam := c.Param("id")
+		if idParam != "0" {
+			cfgID, err := strconv.ParseInt(idParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "unable to parse `id` param.")
+				return
+			}
+			exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
+				return
+			}
+		}
+
+		if err := form.PopulateExportConfig(exportConfig); err != nil {
+			ErrorPage(c, fmt.Sprintf("error processing export config: %v", err))
+		}
+
+		updateFn := exportDB.AddExportConfig
+		if exportConfig.ConfigID != 0 {
+			updateFn = exportDB.UpdateExportConfig
+		}
+		if err := updateFn(ctx, exportConfig); err != nil {
+			ErrorPage(c, fmt.Sprintf("Error writing export config: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Updated export config #%v", exportConfig.ConfigID))
+
+		usedSigInfos := make(map[int64]bool)
+		for _, id := range exportConfig.SignatureInfoIDs {
+			usedSigInfos[id] = true
+		}
+
+		sigInfos, err := exportDB.ListAllSignatureInfos(ctx)
+		if err != nil {
+			ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
+		}
+
+		m["export"] = exportConfig
+		m["usedSigInfos"] = usedSigInfos
+		m["siginfos"] = sigInfos
+		c.HTML(http.StatusOK, "export", m)
+	}
+}
+
+// HandleExportsShow handles the show action for exports.
+func (s *Server) HandleExportsShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		exportDB := database.New(s.env.Database())
+		exportConfig := &model.ExportConfig{}
+		if idParam := c.Param("id"); idParam == "0" {
+			// Default the period to suggest an appropriate value.
+			exportConfig.Period = 24 * time.Hour
+			exportConfig.IncludeTravelers = true // Encourage this default.
+		} else {
+			cfgID, err := strconv.ParseInt(idParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "unable to parse `id` param.")
+				return
+			}
+			exportConfig, err = exportDB.GetExportConfig(ctx, cfgID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error loading export config: %v", err))
+				return
+			}
+		}
+
+		usedSigInfos := make(map[int64]bool)
+		for _, id := range exportConfig.SignatureInfoIDs {
+			usedSigInfos[id] = true
+		}
+
+		sigInfos, err := exportDB.ListAllSignatureInfos(ctx)
+		if err != nil {
+			ErrorPage(c, fmt.Sprintf("Error reading the database: %v", err))
+		}
+
+		m["export"] = exportConfig
+		m["usedSigInfos"] = usedSigInfos
+		m["siginfos"] = sigInfos
+		c.HTML(http.StatusOK, "export", m)
+	}
+}
+
+type exportFormData struct {
+	OutputRegion       string        `form:"output-region"`
+	InputRegions       string        `form:"input-regions"`
+	IncludeTravelers   bool          `form:"include-travelers"`
+	OnlyNonTravelers   bool          `form:"only-non-travelers"`
+	ExcludeRegions     string        `form:"exclude-regions"`
+	BucketName         string        `form:"bucket-name"`
+	FilenameRoot       string        `form:"filename-root"`
+	Period             time.Duration `form:"period"`
+	FromDate           string        `form:"from-date"`
+	FromTime           string        `form:"from-time"`
+	ThruDate           string        `form:"thru-date"`
+	ThruTime           string        `form:"thru-time"`
+	SigInfoIDs         []int64       `form:"sig-info"`
+	MaxRecordsOverride int           `form:"max-records-override"`
+}
+
+// splitRegions turns a string of regions (generally separated by newlines), and
+// breaks them up into an alphabetically sorted slice of strings.
+func splitRegions(regions string) []string {
+	ret := make([]string, 0, 20)
+	for _, s := range strings.Split(regions, "\n") {
+		s := strings.TrimSpace(s)
+		if s != "" {
+			ret = append(ret, s)
+		}
+	}
+	sort.Strings(ret)
+	return ret
+}
+
+func (f *exportFormData) PopulateExportConfig(ec *model.ExportConfig) error {
+	from, err := CombineDateAndTime(f.FromDate, f.FromTime)
+	if err != nil {
+		return err
+	}
+	thru, err := CombineDateAndTime(f.ThruDate, f.ThruTime)
+	if err != nil {
+		return err
+	}
+	if f.IncludeTravelers && f.OnlyNonTravelers {
+		return fmt.Errorf("cannot have both 'include travelers', and 'only non-travelers' set")
+	}
+
+	ec.BucketName = strings.TrimSpace(f.BucketName)
+	ec.FilenameRoot = strings.TrimSpace(f.FilenameRoot)
+	ec.Period = f.Period
+	ec.OutputRegion = strings.TrimSpace(f.OutputRegion)
+	ec.InputRegions = splitRegions(f.InputRegions)
+	ec.IncludeTravelers = f.IncludeTravelers
+	ec.OnlyNonTravelers = f.OnlyNonTravelers
+	ec.ExcludeRegions = splitRegions(f.ExcludeRegions)
+	ec.From = from
+	ec.Thru = thru
+	ec.SignatureInfoIDs = f.SigInfoIDs
+	if f.MaxRecordsOverride > 0 {
+		ec.MaxRecordsOverride = &f.MaxRecordsOverride
+	} else {
+		ec.MaxRecordsOverride = nil
+	}
+
+	if len(ec.SignatureInfoIDs) > 10 {
+		return fmt.Errorf("too many signing keys selected, there is a limit of 10")
+	}
+
+	return nil
+}
diff --git a/internal/admin/handle_health_authority.go b/internal/admin/handle_health_authority.go
new file mode 100644
index 0000000..483f3a7
--- /dev/null
+++ b/internal/admin/handle_health_authority.go
@@ -0,0 +1,240 @@
+// Copyright 2021 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 admin
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+)
+
+// HandleHealthAuthoritySave handles the create/update actions for health
+// authorities.
+func (s *Server) HandleHealthAuthoritySave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form healthAuthorityFormData
+		err := c.Bind(&form)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		haDB := database.New(s.env.Database())
+		healthAuthority := &model.HealthAuthority{}
+		haID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+		if err != nil {
+			ErrorPage(c, "Unable to parse `id` param")
+			return
+		}
+		if haID != 0 {
+			healthAuthority, err = haDB.GetHealthAuthorityByID(ctx, haID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("error processing health authority: %v", err))
+				return
+			}
+		}
+		form.PopulateHealthAuthority(healthAuthority)
+
+		// Decide if update or insert.
+		updateFn := haDB.AddHealthAuthority
+		if haID != 0 {
+			updateFn = haDB.UpdateHealthAuthority
+		}
+		if err := updateFn(ctx, healthAuthority); err != nil {
+			ErrorPage(c, fmt.Sprintf("Error writing health authority: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Updated Health Authority '%v'", healthAuthority.Issuer))
+		m["ha"] = healthAuthority
+		m["hak"] = &model.HealthAuthorityKey{From: time.Now()} // For create form.
+		c.HTML(http.StatusOK, "healthauthority", m)
+	}
+}
+
+// HandleHealthAuthorityShow handles the show action for health authorities.
+func (s *Server) HandleHealthAuthorityShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		healthAuthority := &model.HealthAuthority{
+			EnableStatsAPI: true, // default enabled.
+		}
+		if IDParam := c.Param("id"); IDParam == "0" {
+			m["new"] = true
+		} else {
+			haID, err := strconv.ParseInt(IDParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "Unable to parse `id` param.")
+				return
+			}
+
+			haDB := database.New(s.env.Database())
+			healthAuthority, err = haDB.GetHealthAuthorityByID(ctx, haID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Unable to find requested health authority: %v. Error: %v", haID, err))
+				return
+			}
+		}
+		m["ha"] = healthAuthority
+		m["hak"] = &model.HealthAuthorityKey{From: time.Now()} // For create form.
+		c.HTML(http.StatusOK, "healthauthority", m)
+	}
+}
+
+// HandleHealthAuthorityKeys handles the keys action for health authorities.
+func (s *Server) HandleHealthAuthorityKeys() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+
+		haDB := database.New(s.env.Database())
+		haID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+		if err != nil {
+			ErrorPage(c, "Unable to parse `id` param")
+			return
+		}
+		healthAuthority, err := haDB.GetHealthAuthorityByID(ctx, haID)
+		if err != nil {
+			ErrorPage(c, fmt.Sprintf("error processing health authority: %v", err))
+			return
+		}
+
+		if action := c.Param("action"); action == "create" {
+			var form keyhealthAuthorityFormData
+			err := c.Bind(&form)
+			if err != nil {
+				ErrorPage(c, err.Error())
+				return
+			}
+
+			var hak model.HealthAuthorityKey
+			err = form.PopulateHealthAuthorityKey(&hak)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error parsing new health authority key: %v", err))
+				return
+			}
+			err = haDB.AddHealthAuthorityKey(ctx, healthAuthority, &hak)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error saving health authority key: %v", err))
+				return
+			}
+
+		} else if action == "revoke" || action == "reinstate" || action == "activate" {
+			version := c.Param("version")
+
+			// find the key.
+			var hak *model.HealthAuthorityKey
+			for _, key := range healthAuthority.Keys {
+				if key.Version == version {
+					hak = key
+					break
+				}
+			}
+			if hak == nil {
+				ErrorPage(c, "Invalid key specified")
+				return
+			}
+
+			if action == "activate" {
+				if hak.IsFuture() {
+					hak.From = time.Now()
+				}
+			} else if action == "revoke" {
+				hak.Revoke()
+			} else {
+				hak.Thru = time.Time{}
+			}
+
+			log.Printf("HAK %+v", hak)
+
+			err = haDB.UpdateHealthAuthorityKey(ctx, hak)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error saving health authority key: %v", err))
+				return
+			}
+
+		} else {
+			ErrorPage(c, "invalid action")
+			return
+		}
+		c.Redirect(http.StatusFound, fmt.Sprintf("/healthauthority/%v", healthAuthority.ID))
+	}
+}
+
+type healthAuthorityFormData struct {
+	Issuer         string `form:"issuer"`
+	Audience       string `form:"audience"`
+	Name           string `form:"name"`
+	EnableStatsAPI bool   `form:"enable-stats-api"`
+	JwksURI        string `form:"jwks-uri"`
+}
+
+func (f *healthAuthorityFormData) PopulateHealthAuthority(ha *model.HealthAuthority) {
+	ha.Issuer = f.Issuer
+	ha.Audience = f.Audience
+	ha.Name = f.Name
+	ha.EnableStatsAPI = f.EnableStatsAPI
+	ha.SetJWKS(f.JwksURI)
+}
+
+type keyhealthAuthorityFormData struct {
+	Version  string `form:"version"`
+	PEMBlock string `form:"public-key-pem"`
+	FromDate string `form:"from-date"`
+	FromTime string `form:"from-time"`
+	ThruDate string `form:"Thru-date"`
+	ThruTime string `form:"thru-time"`
+}
+
+func (f *keyhealthAuthorityFormData) FromTimestamp() (time.Time, error) {
+	return CombineDateAndTime(f.FromDate, f.FromTime)
+}
+
+func (f *keyhealthAuthorityFormData) ThruTimestamp() (time.Time, error) {
+	return CombineDateAndTime(f.ThruDate, f.ThruTime)
+}
+
+func (f *keyhealthAuthorityFormData) PopulateHealthAuthorityKey(hak *model.HealthAuthorityKey) error {
+	fTime, err := f.FromTimestamp()
+	if err != nil {
+		return err
+	}
+	tTime, err := f.ThruTimestamp()
+	if err != nil {
+		return err
+	}
+	hak.Version = strings.TrimSpace(f.Version)
+	hak.From = fTime
+	hak.Thru = tTime
+	hak.PublicKeyPEM = strings.TrimSpace(f.PEMBlock)
+
+	_, err = hak.PublicKey()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/admin/handle_healthz.go b/internal/admin/handle_healthz.go
new file mode 100644
index 0000000..02c889c
--- /dev/null
+++ b/internal/admin/handle_healthz.go
@@ -0,0 +1,25 @@
+// Copyright 2021 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 admin
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+func (s *Server) HandleHealthz() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		c.JSON(200, gin.H{"status": "ok"})
+	}
+}
diff --git a/internal/admin/handle_index.go b/internal/admin/handle_index.go
new file mode 100644
index 0000000..32f4415
--- /dev/null
+++ b/internal/admin/handle_index.go
@@ -0,0 +1,86 @@
+// Copyright 2021 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 admin
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	exdb "github.com/google/exposure-notifications-server/internal/export/database"
+	exportimportdatabase "github.com/google/exposure-notifications-server/internal/exportimport/database"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
+	hadb "github.com/google/exposure-notifications-server/internal/verification/database"
+)
+
+func (s *Server) HandleIndex() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		db := s.env.Database()
+
+		// Load authorized apps for index.
+		apps, err := aadb.New(db).ListAuthorizedApps(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["apps"] = apps
+
+		// Load health authorities.
+		has, err := hadb.New(db).ListAllHealthAuthoritiesWithoutKeys(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["healthauthorities"] = has
+
+		// Load export configurations.
+		exports, err := exdb.New(db).GetAllExportConfigs(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["exports"] = exports
+
+		// Load export importer configurations.
+		exportImporters, err := exportimportdatabase.New(db).ListConfigs(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["exportImporters"] = exportImporters
+
+		// Load SignatureInfos
+		sigInfos, err := exdb.New(db).ListAllSignatureInfos(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["siginfos"] = sigInfos
+
+		// Load mirrors
+		mirrors, err := mirrordatabase.New(db).Mirrors(ctx)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+		m["mirrors"] = mirrors
+
+		m.AddTitle("Exposure Notification Key Server - Admin Console")
+		c.HTML(http.StatusOK, "index", m)
+	}
+}
diff --git a/internal/admin/handle_mirrors.go b/internal/admin/handle_mirrors.go
new file mode 100644
index 0000000..1fa893f
--- /dev/null
+++ b/internal/admin/handle_mirrors.go
@@ -0,0 +1,146 @@
+// Copyright 2021 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 admin
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/mirror/database"
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+)
+
+// HandleMirrorsSave handles the create/update actions for mirrors.
+func (s *Server) HandleMirrorsSave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form mirrorFormData
+		if err := c.Bind(&form); err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		db := database.New(s.env.Database())
+		mirror := &model.Mirror{}
+		if idParam := c.Param("id"); idParam != "0" {
+			cfgID, err := strconv.ParseInt(idParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "unable to parse `id` param.")
+				return
+			}
+			mirror, err = db.GetMirror(ctx, cfgID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
+				return
+			}
+		}
+
+		switch form.Action {
+		case "delete":
+			if err := db.DeleteMirror(ctx, mirror); err != nil {
+				ErrorPage(c, fmt.Sprintf("Failed to delete mirror: %v", err))
+				return
+			}
+
+			m.AddSuccess(fmt.Sprintf("Deleted mirror %d", mirror.ID))
+			c.Redirect(http.StatusSeeOther, "/")
+		case "save":
+			if err := form.PopulateMirror(mirror); err != nil {
+				ErrorPage(c, fmt.Sprintf("error processing mirror: %v", err))
+				return
+			}
+
+			updateFn := db.AddMirror
+			if mirror.ID != 0 {
+				updateFn = db.UpdateMirror
+			}
+			if err := updateFn(ctx, mirror); err != nil {
+				ErrorPage(c, fmt.Sprintf("Error writing mirror: %v", err))
+				return
+			}
+
+			m.AddSuccess(fmt.Sprintf("Updated mirror %d", mirror.ID))
+
+			m["mirror"] = mirror
+			c.HTML(http.StatusOK, "mirror", m)
+		default:
+			ErrorPage(c, "Invalid form action")
+		}
+	}
+}
+
+// HandleMirrorsShow handles the show action for mirrors.
+func (s *Server) HandleMirrorsShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		db := database.New(s.env.Database())
+		mirror := &model.Mirror{}
+		if idParam := c.Param("id"); idParam != "0" {
+			id, err := strconv.ParseInt(idParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "unable to parse `id` param.")
+				return
+			}
+			mirror, err = db.GetMirror(ctx, id)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
+				return
+			}
+		}
+
+		var mirrorFiles []*model.MirrorFile
+		if mirror.ID != 0 {
+			var err error
+			mirrorFiles, err = db.ListFiles(ctx, mirror.ID)
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("Error loading mirror files: %v", err))
+				return
+			}
+		}
+
+		m["mirror"] = mirror
+		m["mirrorFiles"] = mirrorFiles
+		c.HTML(http.StatusOK, "mirror", m)
+	}
+}
+
+type mirrorFormData struct {
+	Action string `form:"action" binding:"required"`
+
+	IndexFile          string `form:"index-file" binding:"required"`
+	ExportRoot         string `form:"export-root"`
+	CloudStorageBucket string `form:"cloud-storage-bucket" binding:"required"`
+	FilenameRoot       string `form:"filename-root"`
+	FilenameRewrite    string `form:"filename-rewrite"`
+}
+
+func (f *mirrorFormData) PopulateMirror(m *model.Mirror) error {
+	m.IndexFile = f.IndexFile
+	m.ExportRoot = f.ExportRoot
+	m.CloudStorageBucket = f.CloudStorageBucket
+	m.FilenameRoot = f.FilenameRoot
+	if f.FilenameRewrite != "" {
+		m.FilenameRewrite = &f.FilenameRewrite
+	} else {
+		m.FilenameRewrite = nil
+	}
+	return nil
+}
diff --git a/internal/admin/handle_signature_infos.go b/internal/admin/handle_signature_infos.go
new file mode 100644
index 0000000..be0e603
--- /dev/null
+++ b/internal/admin/handle_signature_infos.go
@@ -0,0 +1,130 @@
+// Copyright 2021 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 admin
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/export/database"
+	"github.com/google/exposure-notifications-server/internal/export/model"
+)
+
+// HandleSignatureInfosSave handles the create/update actions for signature
+// infos.
+func (s *Server) HandleSignatureInfosSave() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		var form signatureInfoFormData
+		err := c.Bind(&form)
+		if err != nil {
+			ErrorPage(c, err.Error())
+			return
+		}
+
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		exportDB := database.New(s.env.Database())
+
+		sigID, err := strconv.Atoi(c.Param("id"))
+		sigInfo := &model.SignatureInfo{}
+		if err != nil {
+			ErrorPage(c, "Unable to parse `id` param.")
+			return
+		}
+		if sigID != 0 {
+			sigInfo, err = exportDB.GetSignatureInfo(ctx, int64(sigID))
+			if err != nil {
+				ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
+				return
+			}
+		}
+		if err := form.PopulateSigInfo(sigInfo); err != nil {
+			ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
+			return
+		}
+
+		// Either insert or update.
+		updateFn := exportDB.AddSignatureInfo
+		if sigID != 0 {
+			updateFn = exportDB.UpdateSignatureInfo
+		}
+		if err := updateFn(ctx, sigInfo); err != nil {
+			ErrorPage(c, fmt.Sprintf("Error writing signature info: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Updated signture info #`%v`", sigInfo.ID))
+		m["siginfo"] = sigInfo
+		c.HTML(http.StatusOK, "siginfo", m)
+	}
+}
+
+// HandleSignatureInfosShow handles the show action for signature infos.
+func (s *Server) HandleSignatureInfosShow() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		m := TemplateMap{}
+
+		sigInfo := &model.SignatureInfo{}
+		if IDParam := c.Param("id"); IDParam == "0" {
+			m["new"] = true
+		} else {
+			sigID, err := strconv.ParseInt(IDParam, 10, 64)
+			if err != nil {
+				ErrorPage(c, "Unable to parse `id` param.")
+				return
+			}
+			exportDB := database.New(s.env.Database())
+			sigInfo, err = exportDB.GetSignatureInfo(ctx, sigID)
+			if err != nil {
+				ErrorPage(c, "error loading signtaure info.")
+				return
+			}
+		}
+
+		m["siginfo"] = sigInfo
+		c.HTML(http.StatusOK, "siginfo", m)
+	}
+}
+
+type signatureInfoFormData struct {
+	SigningKey        string `form:"signing-key"`
+	EndDate           string `form:"end-date"`
+	EndTime           string `form:"end-time"`
+	SigningKeyID      string `form:"signing-key-id"`
+	SigningKeyVersion string `form:"signing-key-version"`
+}
+
+func (f *signatureInfoFormData) EndTimestamp() (time.Time, error) {
+	return CombineDateAndTime(f.EndDate, f.EndTime)
+}
+
+func (f *signatureInfoFormData) PopulateSigInfo(si *model.SignatureInfo) error {
+	ts, err := f.EndTimestamp()
+	if err != nil {
+		return err
+	}
+
+	si.SigningKey = strings.TrimSpace(f.SigningKey)
+	si.SigningKeyVersion = strings.TrimSpace(f.SigningKeyVersion)
+	si.SigningKeyID = f.SigningKeyID
+	si.EndTimestamp = ts
+	return nil
+}
diff --git a/internal/admin/healthauthority/form.go b/internal/admin/healthauthority/form.go
deleted file mode 100644
index f7c36c2..0000000
--- a/internal/admin/healthauthority/form.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// 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 healthauthority is part of the admin system.
-package healthauthority
-
-import (
-	"strings"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/verification/model"
-)
-
-type formData struct {
-	Issuer         string `form:"issuer"`
-	Audience       string `form:"audience"`
-	Name           string `form:"name"`
-	EnableStatsAPI bool   `form:"enable-stats-api"`
-	JwksURI        string `form:"jwks-uri"`
-}
-
-func (f *formData) PopulateHealthAuthority(ha *model.HealthAuthority) {
-	ha.Issuer = f.Issuer
-	ha.Audience = f.Audience
-	ha.Name = f.Name
-	ha.EnableStatsAPI = f.EnableStatsAPI
-	ha.SetJWKS(f.JwksURI)
-}
-
-type keyFormData struct {
-	Version  string `form:"version"`
-	PEMBlock string `form:"public-key-pem"`
-	FromDate string `form:"from-date"`
-	FromTime string `form:"from-time"`
-	ThruDate string `form:"Thru-date"`
-	ThruTime string `form:"thru-time"`
-}
-
-func (f *keyFormData) FromTimestamp() (time.Time, error) {
-	return admin.CombineDateAndTime(f.FromDate, f.FromTime)
-}
-
-func (f *keyFormData) ThruTimestamp() (time.Time, error) {
-	return admin.CombineDateAndTime(f.ThruDate, f.ThruTime)
-}
-
-func (f *keyFormData) PopulateHealthAuthorityKey(hak *model.HealthAuthorityKey) error {
-	fTime, err := f.FromTimestamp()
-	if err != nil {
-		return err
-	}
-	tTime, err := f.ThruTimestamp()
-	if err != nil {
-		return err
-	}
-	hak.Version = strings.TrimSpace(f.Version)
-	hak.From = fTime
-	hak.Thru = tTime
-	hak.PublicKeyPEM = strings.TrimSpace(f.PEMBlock)
-
-	_, err = hak.PublicKey()
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
diff --git a/internal/admin/healthauthority/keys.go b/internal/admin/healthauthority/keys.go
deleted file mode 100644
index 47b4172..0000000
--- a/internal/admin/healthauthority/keys.go
+++ /dev/null
@@ -1,116 +0,0 @@
-// 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 healthauthority
-
-import (
-	"fmt"
-	"log"
-	"net/http"
-	"strconv"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	"github.com/google/exposure-notifications-server/internal/verification/database"
-	"github.com/google/exposure-notifications-server/internal/verification/model"
-)
-
-type keyController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewKeyController(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &keyController{config: c, env: env}
-}
-
-// Handles actions of : Create, Revoke, and Reinstate.
-// Path of /healthauthoritykey/:id/:action/*version
-func (h *keyController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-
-	haDB := database.New(h.env.Database())
-	haID, err := strconv.ParseInt(c.Param("id"), 10, 64)
-	if err != nil {
-		admin.ErrorPage(c, "Unable to parse `id` param")
-		return
-	}
-	healthAuthority, err := haDB.GetHealthAuthorityByID(ctx, haID)
-	if err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("error processing health authority: %v", err))
-		return
-	}
-
-	if action := c.Param("action"); action == "create" {
-		var form keyFormData
-		err := c.Bind(&form)
-		if err != nil {
-			admin.ErrorPage(c, err.Error())
-			return
-		}
-
-		var hak model.HealthAuthorityKey
-		err = form.PopulateHealthAuthorityKey(&hak)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error parsing new health authority key: %v", err))
-			return
-		}
-		err = haDB.AddHealthAuthorityKey(ctx, healthAuthority, &hak)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error saving health authority key: %v", err))
-			return
-		}
-
-	} else if action == "revoke" || action == "reinstate" || action == "activate" {
-		version := c.Param("version")
-
-		// find the key.
-		var hak *model.HealthAuthorityKey
-		for _, key := range healthAuthority.Keys {
-			if key.Version == version {
-				hak = key
-				break
-			}
-		}
-		if hak == nil {
-			admin.ErrorPage(c, "Invalid key specified")
-			return
-		}
-
-		if action == "activate" {
-			if hak.IsFuture() {
-				hak.From = time.Now()
-			}
-		} else if action == "revoke" {
-			hak.Revoke()
-		} else {
-			hak.Thru = time.Time{}
-		}
-
-		log.Printf("HAK %+v", hak)
-
-		err = haDB.UpdateHealthAuthorityKey(ctx, hak)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error saving health authority key: %v", err))
-			return
-		}
-
-	} else {
-		admin.ErrorPage(c, "invalid action")
-		return
-	}
-	c.Redirect(http.StatusFound, fmt.Sprintf("/healthauthority/%v", healthAuthority.ID))
-}
diff --git a/internal/admin/healthauthority/save.go b/internal/admin/healthauthority/save.go
deleted file mode 100644
index 248cea2..0000000
--- a/internal/admin/healthauthority/save.go
+++ /dev/null
@@ -1,80 +0,0 @@
-// 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 healthauthority
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	"github.com/google/exposure-notifications-server/internal/verification/database"
-	"github.com/google/exposure-notifications-server/internal/verification/model"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (h *saveController) Execute(c *gin.Context) {
-	var form formData
-	err := c.Bind(&form)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	haDB := database.New(h.env.Database())
-	healthAuthority := &model.HealthAuthority{}
-	haID, err := strconv.ParseInt(c.Param("id"), 10, 64)
-	if err != nil {
-		admin.ErrorPage(c, "Unable to parse `id` param")
-		return
-	}
-	if haID != 0 {
-		healthAuthority, err = haDB.GetHealthAuthorityByID(ctx, haID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("error processing health authority: %v", err))
-			return
-		}
-	}
-	form.PopulateHealthAuthority(healthAuthority)
-
-	// Decide if update or insert.
-	updateFn := haDB.AddHealthAuthority
-	if haID != 0 {
-		updateFn = haDB.UpdateHealthAuthority
-	}
-	if err := updateFn(ctx, healthAuthority); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("Error writing health authority: %v", err))
-		return
-	}
-
-	m.AddSuccess(fmt.Sprintf("Updated Health Authority '%v'", healthAuthority.Issuer))
-	m["ha"] = healthAuthority
-	m["hak"] = &model.HealthAuthorityKey{From: time.Now()} // For create form.
-	c.HTML(http.StatusOK, "healthauthority", m)
-}
diff --git a/internal/admin/healthauthority/view.go b/internal/admin/healthauthority/view.go
deleted file mode 100644
index 1544847..0000000
--- a/internal/admin/healthauthority/view.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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 healthauthority
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-	"time"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	"github.com/google/exposure-notifications-server/internal/verification/database"
-	"github.com/google/exposure-notifications-server/internal/verification/model"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func (v *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	healthAuthority := &model.HealthAuthority{
-		EnableStatsAPI: true, // default enabled.
-	}
-	if IDParam := c.Param("id"); IDParam == "0" {
-		m["new"] = true
-	} else {
-		haID, err := strconv.ParseInt(IDParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "Unable to parse `id` param.")
-			return
-		}
-
-		haDB := database.New(v.env.Database())
-		healthAuthority, err = haDB.GetHealthAuthorityByID(ctx, haID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Unable to find requested health authority: %v. Error: %v", haID, err))
-			return
-		}
-	}
-	m["ha"] = healthAuthority
-	m["hak"] = &model.HealthAuthorityKey{From: time.Now()} // For create form.
-	c.HTML(http.StatusOK, "healthauthority", m)
-}
diff --git a/internal/admin/index/index.go b/internal/admin/index/index.go
deleted file mode 100644
index e81eb62..0000000
--- a/internal/admin/index/index.go
+++ /dev/null
@@ -1,96 +0,0 @@
-// 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 index contains admin console indexHandler for the main landing page.
-package index
-
-import (
-	"net/http"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
-	exdb "github.com/google/exposure-notifications-server/internal/export/database"
-	exportimportdatabase "github.com/google/exposure-notifications-server/internal/exportimport/database"
-	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-	hadb "github.com/google/exposure-notifications-server/internal/verification/database"
-)
-
-type indexHandler struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func New(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &indexHandler{config: c, env: env}
-}
-
-func (h *indexHandler) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	db := h.env.Database()
-
-	// Load authorized apps for index.
-	apps, err := aadb.New(db).ListAuthorizedApps(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["apps"] = apps
-
-	// Load health authorities.
-	has, err := hadb.New(db).ListAllHealthAuthoritiesWithoutKeys(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["healthauthorities"] = has
-
-	// Load export configurations.
-	exports, err := exdb.New(db).GetAllExportConfigs(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["exports"] = exports
-
-	// Load export importer configurations.
-	exportImporters, err := exportimportdatabase.New(db).ListConfigs(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["exportImporters"] = exportImporters
-
-	// Load SignatureInfos
-	sigInfos, err := exdb.New(db).ListAllSignatureInfos(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["siginfos"] = sigInfos
-
-	// Load mirrors
-	mirrors, err := mirrordatabase.New(db).Mirrors(ctx)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-	m["mirrors"] = mirrors
-
-	m.AddTitle("Exposure Notification Key Server - Admin Console")
-	c.HTML(http.StatusOK, "index", m)
-}
diff --git a/internal/admin/mirrors/form.go b/internal/admin/mirrors/form.go
deleted file mode 100644
index d21ecb2..0000000
--- a/internal/admin/mirrors/form.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// 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 mirrors is part of the admin system.
-package mirrors
-
-import (
-	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
-)
-
-type formData struct {
-	Action string `form:"action" binding:"required"`
-
-	IndexFile          string `form:"index-file" binding:"required"`
-	ExportRoot         string `form:"export-root"`
-	CloudStorageBucket string `form:"cloud-storage-bucket" binding:"required"`
-	FilenameRoot       string `form:"filename-root"`
-	FilenameRewrite    string `form:"filename-rewrite"`
-}
-
-func (f *formData) PopulateMirror(m *mirrormodel.Mirror) error {
-	m.IndexFile = f.IndexFile
-	m.ExportRoot = f.ExportRoot
-	m.CloudStorageBucket = f.CloudStorageBucket
-	m.FilenameRoot = f.FilenameRoot
-	if f.FilenameRewrite != "" {
-		m.FilenameRewrite = &f.FilenameRewrite
-	} else {
-		m.FilenameRewrite = nil
-	}
-	return nil
-}
diff --git a/internal/admin/mirrors/save.go b/internal/admin/mirrors/save.go
deleted file mode 100644
index c2232a9..0000000
--- a/internal/admin/mirrors/save.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// 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 mirrors
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
-	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (v *saveController) Execute(c *gin.Context) {
-	var form formData
-	if err := c.Bind(&form); err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	db := mirrordatabase.New(v.env.Database())
-	mirror := &mirrormodel.Mirror{}
-	if idParam := c.Param("id"); idParam != "0" {
-		cfgID, err := strconv.ParseInt(idParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "unable to parse `id` param.")
-			return
-		}
-		mirror, err = db.GetMirror(ctx, cfgID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
-			return
-		}
-	}
-
-	switch form.Action {
-	case "delete":
-		if err := db.DeleteMirror(ctx, mirror); err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Failed to delete mirror: %v", err))
-			return
-		}
-
-		m.AddSuccess(fmt.Sprintf("Deleted mirror %d", mirror.ID))
-		c.Redirect(http.StatusSeeOther, "/")
-	case "save":
-		if err := form.PopulateMirror(mirror); err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("error processing mirror: %v", err))
-			return
-		}
-
-		updateFn := db.AddMirror
-		if mirror.ID != 0 {
-			updateFn = db.UpdateMirror
-		}
-		if err := updateFn(ctx, mirror); err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error writing mirror: %v", err))
-			return
-		}
-
-		m.AddSuccess(fmt.Sprintf("Updated mirror %d", mirror.ID))
-
-		m["mirror"] = mirror
-		c.HTML(http.StatusOK, "mirror", m)
-	default:
-		admin.ErrorPage(c, "Invalid form action")
-	}
-}
diff --git a/internal/admin/mirrors/view.go b/internal/admin/mirrors/view.go
deleted file mode 100644
index ef8b3ef..0000000
--- a/internal/admin/mirrors/view.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// 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 mirrors
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
-	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func (v *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	db := mirrordatabase.New(v.env.Database())
-	mirror := &mirrormodel.Mirror{}
-	if idParam := c.Param("id"); idParam != "0" {
-		id, err := strconv.ParseInt(idParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "unable to parse `id` param.")
-			return
-		}
-		mirror, err = db.GetMirror(ctx, id)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
-			return
-		}
-	}
-
-	var mirrorFiles []*mirrormodel.MirrorFile
-	if mirror.ID != 0 {
-		var err error
-		mirrorFiles, err = db.ListFiles(ctx, mirror.ID)
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror files: %v", err))
-			return
-		}
-	}
-
-	m["mirror"] = mirror
-	m["mirrorFiles"] = mirrorFiles
-	c.HTML(http.StatusOK, "mirror", m)
-}
diff --git a/internal/admin/server.go b/internal/admin/server.go
new file mode 100644
index 0000000..fa17f4e
--- /dev/null
+++ b/internal/admin/server.go
@@ -0,0 +1,81 @@
+// Copyright 2021 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 admin
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+// Server is the debugger server.
+type Server struct {
+	config *Config
+	env    *serverenv.ServerEnv
+}
+
+// NewServer makes a new admin console server.
+func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
+	if env.Database() == nil {
+		return nil, fmt.Errorf("missing Database in server env")
+	}
+
+	return &Server{
+		config: config,
+		env:    env,
+	}, nil
+}
+
+func (s *Server) Routes(ctx context.Context) http.Handler {
+	mux := gin.Default()
+	mux.SetFuncMap(TemplateFuncMap)
+	mux.LoadHTMLGlob(s.config.TemplatePath + "/*")
+
+	// Landing page.
+	mux.GET("/", s.HandleIndex())
+
+	// Authorized App Handling.
+	mux.GET("/app", s.HandleAuthorizedAppsShow())
+	mux.POST("/app", s.HandleAuthorizedAppsSave())
+
+	// HealthAuthority[Key] Handling.
+	mux.GET("/healthauthority/:id", s.HandleHealthAuthorityShow())
+	mux.POST("/healthauthority/:id", s.HandleHealthAuthoritySave())
+	mux.POST("/healthauthoritykey/:id/:action/:version", s.HandleHealthAuthorityKeys())
+
+	// Export Config Handling.
+	mux.GET("/exports/:id", s.HandleExportsShow())
+	mux.POST("/exports/:id", s.HandleExportsSave())
+
+	// Export importer configuration
+	mux.GET("/export-importers/:id", s.HandleExportImportersShow())
+	mux.POST("/export-importers/:id", s.HandleExportImportersSave())
+
+	// Mirror handling.
+	mux.GET("/mirrors/:id", s.HandleMirrorsShow())
+	mux.POST("/mirrors/:id", s.HandleMirrorsSave())
+
+	// Signature Info.
+	mux.GET("/siginfo/:id", s.HandleSignatureInfosShow())
+	mux.POST("/siginfo/:id", s.HandleSignatureInfosSave())
+
+	// Healthz.
+	mux.GET("/health", s.HandleHealthz())
+
+	return mux
+}
diff --git a/internal/admin/siginfo/form.go b/internal/admin/siginfo/form.go
deleted file mode 100644
index b2ce0cc..0000000
--- a/internal/admin/siginfo/form.go
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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 siginfo is part of the admin system.
-package siginfo
-
-import (
-	"strings"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-)
-
-type formData struct {
-	SigningKey        string `form:"signing-key"`
-	EndDate           string `form:"end-date"`
-	EndTime           string `form:"end-time"`
-	SigningKeyID      string `form:"signing-key-id"`
-	SigningKeyVersion string `form:"signing-key-version"`
-}
-
-func (f *formData) EndTimestamp() (time.Time, error) {
-	return admin.CombineDateAndTime(f.EndDate, f.EndTime)
-}
-
-func (f *formData) PopulateSigInfo(si *model.SignatureInfo) error {
-	ts, err := f.EndTimestamp()
-	if err != nil {
-		return err
-	}
-
-	si.SigningKey = strings.TrimSpace(f.SigningKey)
-	si.SigningKeyVersion = strings.TrimSpace(f.SigningKeyVersion)
-	si.SigningKeyID = f.SigningKeyID
-	si.EndTimestamp = ts
-	return nil
-}
diff --git a/internal/admin/siginfo/save.go b/internal/admin/siginfo/save.go
deleted file mode 100644
index d968a39..0000000
--- a/internal/admin/siginfo/save.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// 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 siginfo
-
-import (
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type saveController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &saveController{config: c, env: env}
-}
-
-func (h *saveController) Execute(c *gin.Context) {
-	var form formData
-	err := c.Bind(&form)
-	if err != nil {
-		admin.ErrorPage(c, err.Error())
-		return
-	}
-
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	exportDB := database.New(h.env.Database())
-
-	sigID, err := strconv.Atoi(c.Param("id"))
-	sigInfo := &model.SignatureInfo{}
-	if err != nil {
-		admin.ErrorPage(c, "Unable to parse `id` param.")
-		return
-	}
-	if sigID != 0 {
-		sigInfo, err = exportDB.GetSignatureInfo(ctx, int64(sigID))
-		if err != nil {
-			admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
-			return
-		}
-	}
-	if err := form.PopulateSigInfo(sigInfo); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
-		return
-	}
-
-	// Either insert or update.
-	updateFn := exportDB.AddSignatureInfo
-	if sigID != 0 {
-		updateFn = exportDB.UpdateSignatureInfo
-	}
-	if err := updateFn(ctx, sigInfo); err != nil {
-		admin.ErrorPage(c, fmt.Sprintf("Error writing signature info: %v", err))
-		return
-	}
-
-	m.AddSuccess(fmt.Sprintf("Updated signture info #`%v`", sigInfo.ID))
-	m["siginfo"] = sigInfo
-	c.HTML(http.StatusOK, "siginfo", m)
-}
diff --git a/internal/admin/siginfo/view.go b/internal/admin/siginfo/view.go
deleted file mode 100644
index 2bec227..0000000
--- a/internal/admin/siginfo/view.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// 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 siginfo
-
-import (
-	"net/http"
-	"strconv"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-	"github.com/google/exposure-notifications-server/internal/serverenv"
-)
-
-type viewController struct {
-	config *admin.Config
-	env    *serverenv.ServerEnv
-}
-
-func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
-	return &viewController{config: c, env: env}
-}
-
-func (v *viewController) Execute(c *gin.Context) {
-	ctx := c.Request.Context()
-	m := admin.TemplateMap{}
-
-	sigInfo := &model.SignatureInfo{}
-	if IDParam := c.Param("id"); IDParam == "0" {
-		m["new"] = true
-	} else {
-		sigID, err := strconv.ParseInt(IDParam, 10, 64)
-		if err != nil {
-			admin.ErrorPage(c, "Unable to parse `id` param.")
-			return
-		}
-		exportDB := database.New(v.env.Database())
-		sigInfo, err = exportDB.GetSignatureInfo(ctx, sigID)
-		if err != nil {
-			admin.ErrorPage(c, "error loading signtaure info.")
-			return
-		}
-	}
-
-	m["siginfo"] = sigInfo
-	c.HTML(http.StatusOK, "siginfo", m)
-}
diff --git a/internal/exportimport/import.go b/internal/exportimport/import.go
index 24f3a25..8626a36 100644
--- a/internal/exportimport/import.go
+++ b/internal/exportimport/import.go
@@ -44,7 +44,7 @@ type ImportRequest struct {
 	file         *model.ImportFile
 }
 
-type ImportResposne struct {
+type ImportResponse struct {
 	insertedKeys uint32
 	revisedKeys  uint32
 	droppedKeys  uint32
@@ -55,11 +55,11 @@ type SignatureAndKey struct {
 	publicKey *ecdsa.PublicKey
 }
 
-func (s *Server) ImportExportFile(ctx context.Context, ir *ImportRequest) (*ImportResposne, error) {
+func (s *Server) ImportExportFile(ctx context.Context, ir *ImportRequest) (*ImportResponse, error) {
 	// Special case - previous versions may have inserted the filename root as a file.
 	// If we find that, skip attempted processing and just mark as successful.
 	if ir.exportImport.ExportRoot == ir.file.ZipFilename {
-		return &ImportResposne{
+		return &ImportResponse{
 			insertedKeys: 0,
 			revisedKeys:  0,
 			droppedKeys:  0,
@@ -155,7 +155,7 @@ func (s *Server) ImportExportFile(ctx context.Context, ir *ImportRequest) (*Impo
 		},
 		logger: logger,
 	}
-	response := ImportResposne{}
+	response := ImportResponse{}
 
 	// Go through primary keys and insert.
 	// Must be separate from revised keys in the event both are in the same file.
@@ -191,7 +191,7 @@ func (s *Server) ImportExportFile(ctx context.Context, ir *ImportRequest) (*Impo
 	return &response, nil
 }
 
-func (s *Server) insertAndReviseKeys(ctx context.Context, mode string, exposures []*pubmodel.Exposure, template *pubdb.InsertAndReviseExposuresRequest, response *ImportResposne) error {
+func (s *Server) insertAndReviseKeys(ctx context.Context, mode string, exposures []*pubmodel.Exposure, template *pubdb.InsertAndReviseExposuresRequest, response *ImportResponse) error {
 	logger := logging.FromContext(ctx)
 	length := len(exposures)
 	for i := 0; i < length; i = i + s.config.MaxInsertBatchSize {
@@ -245,8 +245,8 @@ func (t *transformer) transform(keys []*exportproto.TemporaryExposureKey) ([]*pu
 		exp.ImportFileID = &t.importFileID
 
 		// Adjust created at time, if this key is not yet expired.
-		if expTime := pubmodel.TimeForIntervalNumber(exp.IntervalNumber + exp.IntervalCount); expTime.Before(exp.CreatedAt) {
-			exp.CreatedAt = exp.CreatedAt.Add(t.truncateWindow).Truncate(t.truncateWindow)
+		if expTime := pubmodel.TimeForIntervalNumber(exp.IntervalNumber + exp.IntervalCount); exp.CreatedAt.Before(expTime) {
+			exp.CreatedAt = expTime.UTC().Add(t.truncateWindow).Truncate(t.truncateWindow)
 		}
 
 		inserts = append(inserts, exp)
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index f0125ed..d1873e8 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -39,7 +39,7 @@ var (
 	ErrorExposureKeyMismatch = fmt.Errorf("attempted to revise a key with a different key")
 	// ErrorNonLocalProvenance - key revision attempted on federated key, which is not allowed
 	ErrorNonLocalProvenance = fmt.Errorf("key not origionally uploaded to this server, cannot revise")
-	// ErrorNotSameFederationSource - if a key arrived by federation, it can onlybe be revised by the same query (same source)
+	// ErrorNotSameFederationSource - if a key arrived by federation, it can only be be revised by the same query (same source)
 	ErrorNotSameFederationSource = fmt.Errorf("key cannot be revised by a different federation query")
 	// ErrorKeyAlreadyRevised - attempt to revise a key that has already been revised.
 	ErrorKeyAlreadyRevised = fmt.Errorf("key has already been revised and cannot be revised again")
@@ -93,6 +93,9 @@ type Exposure struct {
 	base64Key string
 }
 
+// ExportImportConfig represents the configuration for
+// processing of export files from other systems and how they are imported
+// into this server.
 type ExportImportConfig struct {
 	DefaultReportType         string
 	BackfillSymptomOnset      bool
@@ -333,6 +336,8 @@ func (e *Exposure) ExposureKeyBase64() string {
 	return e.base64Key
 }
 
+// AdjustAndValidate both validates the kay and if necessary makes adjustments
+// to the timing field (createdAt).
 func (e *Exposure) AdjustAndValidate(settings *KeyTransform) error {
 	// Validate individual pieces of the exposure key
 	if l := len(e.ExposureKey); l != verifyapi.KeyLength {
@@ -390,15 +395,8 @@ func DaysBetweenIntervals(a int32, b int32) int32 {
 	a = IntervalNumber(timeutils.UTCMidnight(TimeForIntervalNumber(a)))
 	b = IntervalNumber(timeutils.UTCMidnight(TimeForIntervalNumber(b)))
 	distance := b - a
+	// This will always divide evenly since a and b are aligned on UTC midnight boundaries.
 	days := distance / verifyapi.MaxIntervalCount
-	// if the days don't divide evenly go to 1 magnitude larger.
-	if rem := distance % verifyapi.MaxIntervalCount; rem != 0 {
-		if days < 0 {
-			days--
-		} else {
-			days++
-		}
-	}
 	return days
 }
 
diff --git a/terraform/service_admin_console.tf b/terraform/service_admin_console.tf
new file mode 100644
index 0000000..a1c69cf
--- /dev/null
+++ b/terraform/service_admin_console.tf
@@ -0,0 +1,181 @@
+# Copyright 2021 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" "admin-console" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-admin-console-sa"
+  display_name = "Exposure Notification Admin Console"
+}
+
+resource "google_service_account_iam_member" "cloudbuild-deploy-admin-console" {
+  service_account_id = google_service_account.admin-console.id
+  role               = "roles/iam.serviceAccountUser"
+  member             = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
+
+  depends_on = [
+    google_project_service.services["cloudbuild.googleapis.com"],
+  ]
+}
+
+resource "google_secret_manager_secret_iam_member" "admin-console-db" {
+  for_each = toset([
+    "sslcert",
+    "sslkey",
+    "sslrootcert",
+    "password",
+  ])
+
+  secret_id = google_secret_manager_secret.db-secret[each.key].id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.admin-console.email}"
+}
+
+resource "google_project_iam_member" "admin-console-observability" {
+  for_each = toset([
+    "roles/cloudtrace.agent",
+    "roles/logging.logWriter",
+    "roles/monitoring.metricWriter",
+    "roles/stackdriver.resourceMetadata.writer",
+  ])
+
+  project = var.project
+  role    = each.key
+  member  = "serviceAccount:${google_service_account.admin-console.email}"
+}
+
+resource "google_cloud_run_service" "admin-console" {
+  name     = "admin-console"
+  location = var.cloudrun_location
+
+  autogenerate_revision_name = true
+
+  metadata {
+    annotations = merge(
+      local.default_service_annotations,
+      var.default_service_annotations_overrides,
+      lookup(var.service_annotations, "admin-console", {}),
+    )
+  }
+  template {
+    spec {
+      service_account_name = google_service_account.admin-console.email
+
+      containers {
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/admin-console:initial"
+
+        resources {
+          limits = {
+            cpu    = "1000m"
+            memory = "1G"
+          }
+        }
+
+        dynamic "env" {
+          for_each = merge(
+            local.common_cloudrun_env_vars,
+
+            // This MUST come last to allow overrides!
+            lookup(var.service_environment, "admin-console", {}),
+          )
+
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
+      }
+
+      container_concurrency = 10
+    }
+
+    metadata {
+      annotations = merge(
+        local.default_revision_annotations,
+        var.default_revision_annotations_overrides,
+        lookup(var.revision_annotations, "admin-console", {}),
+      )
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    google_secret_manager_secret_iam_member.admin-console-db,
+    null_resource.build,
+    null_resource.migrate,
+  ]
+
+  lifecycle {
+    ignore_changes = [
+      template[0].metadata[0].annotations["client.knative.dev/user-image"],
+      template[0].metadata[0].annotations["run.googleapis.com/client-name"],
+      template[0].metadata[0].annotations["run.googleapis.com/client-version"],
+      template[0].spec[0].containers[0].image,
+      metadata[0].annotations["run.googleapis.com/ingress-status"],
+      metadata[0].labels["cloud.googleapis.com/location"],
+    ]
+  }
+}
+
+resource "google_cloud_run_service_iam_member" "admin-console-invoker" {
+  for_each = toset(var.admin_console_invokers)
+
+  project  = google_cloud_run_service.admin-console.project
+  location = google_cloud_run_service.admin-console.location
+  service  = google_cloud_run_service.admin-console.name
+  role     = "roles/run.invoker"
+  member   = each.key
+}
+
+#
+# Custom domains and load balancer
+#
+
+resource "google_compute_region_network_endpoint_group" "admin-console" {
+  count = length(var.admin_console_hosts) > 0 ? 1 : 0
+
+  name     = "admin-console"
+  provider = google-beta
+  project  = var.project
+  region   = var.region
+
+  network_endpoint_type = "SERVERLESS"
+
+  cloud_run {
+    service = google_cloud_run_service.admin-console.name
+  }
+}
+
+resource "google_compute_backend_service" "admin-console" {
+  count = length(var.admin_console_hosts) > 0 ? 1 : 0
+
+  provider = google-beta
+  name     = "admin-console"
+  project  = var.project
+
+  backend {
+    group = google_compute_region_network_endpoint_group.admin-console[0].id
+  }
+  security_policy = google_compute_security_policy.cloud-armor.name
+  log_config {
+    enable = var.enable_lb_logging
+  }
+}
+
+output "admin_console_urls" {
+  value = concat([google_cloud_run_service.admin-console.status.0.url], formatlist("https://%s", var.admin_console_hosts))
+}
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 6e3caf3..66c8bff 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -200,6 +200,13 @@ variable "enable_cdn_for_exports" {
   description = "Enable Cloud CDN on the export bucket."
 }
 
+variable "admin_console_invokers" {
+  type    = list(string)
+  default = []
+
+  description = "List of IAM entities that can invoke the admin-console. This should be of the form user:[email], serviceAccount:[email], or group:[email]."
+}
+
 variable "debugger_invokers" {
   type    = list(string)
   default = []
@@ -214,6 +221,13 @@ variable "service_environment" {
   description = "Per-service environment overrides."
 }
 
+variable "admin_console_hosts" {
+  type    = list(string)
+  default = []
+
+  description = "List of domains upon which the admin console is served."
+}
+
 variable "debugger_hosts" {
   type    = list(string)
   default = []
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
deleted file mode 100644
index afe9e6d..0000000
--- a/tools/admin-console/main.go
+++ /dev/null
@@ -1,95 +0,0 @@
-// 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"
-
-	"github.com/gin-gonic/gin"
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/admin/authorizedapps"
-	"github.com/google/exposure-notifications-server/internal/admin/exportimporters"
-	"github.com/google/exposure-notifications-server/internal/admin/exports"
-	"github.com/google/exposure-notifications-server/internal/admin/healthauthority"
-	"github.com/google/exposure-notifications-server/internal/admin/index"
-	"github.com/google/exposure-notifications-server/internal/admin/mirrors"
-	"github.com/google/exposure-notifications-server/internal/admin/siginfo"
-	"github.com/google/exposure-notifications-server/internal/setup"
-)
-
-func main() {
-	ctx := context.Background()
-
-	var config admin.Config
-	env, err := setup.Setup(ctx, &config)
-	if err != nil {
-		log.Fatalf("setup.Setup: %v", err)
-	}
-	defer env.Close(ctx)
-
-	router := gin.Default()
-	router.SetFuncMap(admin.TemplateFuncMap)
-	router.LoadHTMLGlob(config.TemplatePath + "/*")
-
-	// Landing page.
-	indexController := index.New(&config, env)
-	router.GET("/", indexController.Execute)
-
-	// Authorized App Handling.
-	authAppController := authorizedapps.NewView(&config, env)
-	router.GET("/app", authAppController.Execute)
-	saveAppController := authorizedapps.NewSave(&config, env)
-	router.POST("/app", saveAppController.Execute)
-
-	// HealthAuthority[Key] Handling.
-	healthAppController := healthauthority.NewView(&config, env)
-	router.GET("/healthauthority/:id", healthAppController.Execute)
-	saveHAController := healthauthority.NewSave(&config, env)
-	router.POST("/healthauthority/:id", saveHAController.Execute)
-	healthAuthorityKeyController := healthauthority.NewKeyController(&config, env)
-	router.POST("/healthauthoritykey/:id/:action/:version", healthAuthorityKeyController.Execute)
-
-	// Export Config Handling.
-	exportConfigController := exports.NewView(&config, env)
-	router.GET("/exports/:id", exportConfigController.Execute)
-	saveExportConfigController := exports.NewSave(&config, env)
-	router.POST("/exports/:id", saveExportConfigController.Execute)
-
-	// Export importer configuration
-	exportImporterController := exportimporters.NewView(&config, env)
-	router.GET("/export-importers/:id", exportImporterController.Execute)
-	saveExportImporterController := exportimporters.NewSave(&config, env)
-	router.POST("/export-importers/:id", saveExportImporterController.Execute)
-
-	// Mirror handling.
-	mirrorController := mirrors.NewView(&config, env)
-	router.GET("/mirrors/:id", mirrorController.Execute)
-	saveMirrorController := mirrors.NewSave(&config, env)
-	router.POST("/mirrors/:id", saveMirrorController.Execute)
-
-	// Signature Info.
-	sigInfoController := siginfo.NewView(&config, env)
-	router.GET("/siginfo/:id", sigInfoController.Execute)
-	saveSigInfoController := siginfo.NewSave(&config, env)
-	router.POST("/siginfo/:id", saveSigInfoController.Execute)
-
-	log.Printf("listening on http://localhost:" + config.Port)
-	if err := router.Run(); err != nil {
-		log.Fatal(err)
-	}
-}


Test patch:
diff --git a/internal/admin/exports/view_test.go b/internal/admin/exports/view_test.go
deleted file mode 100644
index daef59e..0000000
--- a/internal/admin/exports/view_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// 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 exports
-
-import (
-	"net/http/httptest"
-	"testing"
-
-	"github.com/google/exposure-notifications-server/internal/admin"
-	"github.com/google/exposure-notifications-server/internal/export/model"
-)
-
-func TestRenderSignatureInfo(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/export.html
-	// That is what caused the test failure.
-	m := admin.TemplateMap{}
-	exportConfig := &model.ExportConfig{}
-	m["export"] = exportConfig
-
-	sigInfos := []*model.SignatureInfo{
-		{ID: 5},
-	}
-	usedSigInfos := map[int64]bool{5: true}
-	m["usedSigInfos"] = usedSigInfos
-	m["siginfos"] = sigInfos
-
-	recorder := httptest.NewRecorder()
-	config := admin.Config{
-		TemplatePath: "../../../tools/admin-console/templates",
-		TopFile:      "top",
-		BotFile:      "bottom",
-	}
-	err := config.RenderTemplate(recorder, "export", m)
-	if err != nil {
-		t.Fatalf("error rendering template: %v", err)
-	}
-}
diff --git a/internal/admin/authorizedapps/view_test.go b/internal/admin/handle_authorized_apps_test.go
similarity index 67%
rename from internal/admin/authorizedapps/view_test.go
rename to internal/admin/handle_authorized_apps_test.go
index 2d56938..fee3610 100644
--- a/internal/admin/authorizedapps/view_test.go
+++ b/internal/admin/handle_authorized_apps_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 Google LLC
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,30 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package authorizedapps
+package admin
 
 import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/admin"
 	"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/authorizedapp.html
-	// That is what caused the test failure.
-	m := admin.TemplateMap{}
+	m := TemplateMap{}
 	authorizedApp := model.NewAuthorizedApp()
 	m["app"] = authorizedApp
 
 	recorder := httptest.NewRecorder()
-	config := admin.Config{
-		TemplatePath: "../../../tools/admin-console/templates",
+	config := Config{
+		TemplatePath: "../../cmd/admin-console/templates",
 		TopFile:      "top",
 		BotFile:      "bottom",
 	}
diff --git a/internal/admin/exportimporters/view_test.go b/internal/admin/handle_export_importers_test.go
similarity index 55%
rename from internal/admin/exportimporters/view_test.go
rename to internal/admin/handle_export_importers_test.go
index 3166670..68e68cd 100644
--- a/internal/admin/exportimporters/view_test.go
+++ b/internal/admin/handle_export_importers_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 Google LLC
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,30 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package exportimporters
+package admin
 
 import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/admin"
-	exportimportmodel "github.com/google/exposure-notifications-server/internal/exportimport/model"
+	"github.com/google/exposure-notifications-server/internal/exportimport/model"
 )
 
-func TestRenderSignatureInfo(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/siginfo.html
-	// That is what caused the test failure.
-	m := admin.TemplateMap{}
-	model := new(exportimportmodel.ExportImport)
+func TestRednerExportImporters(t *testing.T) {
+	m := TemplateMap{}
+	model := new(model.ExportImport)
 	m["model"] = model
 
 	recorder := httptest.NewRecorder()
-	config := admin.Config{
-		TemplatePath: "../../../tools/admin-console/templates",
+	config := Config{
+		TemplatePath: "../../cmd/admin-console/templates",
 		TopFile:      "top",
 		BotFile:      "bottom",
 	}
diff --git a/internal/admin/exports/form_test.go b/internal/admin/handle_exports_test.go
similarity index 61%
rename from internal/admin/exports/form_test.go
rename to internal/admin/handle_exports_test.go
index 6abf12f..d59b621 100644
--- a/internal/admin/exports/form_test.go
+++ b/internal/admin/handle_exports_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 Google LLC
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,13 +12,40 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package exports
+package admin
 
 import (
+	"net/http/httptest"
 	"reflect"
 	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/export/model"
 )
 
+func TestRenderExports(t *testing.T) {
+	m := TemplateMap{}
+	exportConfig := &model.ExportConfig{}
+	m["export"] = exportConfig
+
+	sigInfos := []*model.SignatureInfo{
+		{ID: 5},
+	}
+	usedSigInfos := map[int64]bool{5: true}
+	m["usedSigInfos"] = usedSigInfos
+	m["siginfos"] = sigInfos
+
+	recorder := httptest.NewRecorder()
+	config := Config{
+		TemplatePath: "../../cmd/admin-console/templates",
+		TopFile:      "top",
+		BotFile:      "bottom",
+	}
+	err := config.RenderTemplate(recorder, "export", m)
+	if err != nil {
+		t.Fatalf("error rendering template: %v", err)
+	}
+}
+
 func TestSplitRegions(t *testing.T) {
 	tests := []struct {
 		r string
diff --git a/internal/admin/mirrors/view_test.go b/internal/admin/handle_mirrors_test.go
similarity index 69%
rename from internal/admin/mirrors/view_test.go
rename to internal/admin/handle_mirrors_test.go
index cad26e6..98fc162 100644
--- a/internal/admin/mirrors/view_test.go
+++ b/internal/admin/handle_mirrors_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 Google LLC
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,24 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package mirrors
+package admin
 
 import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/admin"
-	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
 )
 
-func TestRender(t *testing.T) {
-	m := admin.TemplateMap{}
-	mirror := &mirrormodel.Mirror{}
+func TestRenderMirrors(t *testing.T) {
+	m := TemplateMap{}
+	mirror := &model.Mirror{}
 	m["mirror"] = mirror
 
 	recorder := httptest.NewRecorder()
-	config := admin.Config{
-		TemplatePath: "../../../tools/admin-console/templates",
+	config := Config{
+		TemplatePath: "../../cmd/admin-console/templates",
 		TopFile:      "top",
 		BotFile:      "bottom",
 	}
diff --git a/internal/admin/siginfo/view_test.go b/internal/admin/handle_signature_infos_test.go
similarity index 67%
rename from internal/admin/siginfo/view_test.go
rename to internal/admin/handle_signature_infos_test.go
index 6d509c4..2a9bfe4 100644
--- a/internal/admin/siginfo/view_test.go
+++ b/internal/admin/handle_signature_infos_test.go
@@ -1,4 +1,4 @@
-// Copyright 2020 Google LLC
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,30 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package siginfo
+package admin
 
 import (
 	"net/http/httptest"
 	"testing"
 
-	"github.com/google/exposure-notifications-server/internal/admin"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 )
 
 func TestRenderSignatureInfo(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/siginfo.html
-	// That is what caused the test failure.
-	m := admin.TemplateMap{}
+	m := TemplateMap{}
 	sigInfo := &model.SignatureInfo{}
 	m["siginfo"] = sigInfo
 
 	recorder := httptest.NewRecorder()
-	config := admin.Config{
-		TemplatePath: "../../../tools/admin-console/templates",
+	config := Config{
+		TemplatePath: "../../cmd/admin-console/templates",
 		TopFile:      "top",
 		BotFile:      "bottom",
 	}
diff --git a/internal/exportimport/import_test.go b/internal/exportimport/import_test.go
new file mode 100644
index 0000000..92b0d8e
--- /dev/null
+++ b/internal/exportimport/import_test.go
@@ -0,0 +1,138 @@
+// Copyright 2021 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 exportimport
+
+import (
+	"context"
+	"sync"
+	"testing"
+	"time"
+
+	exportproto "github.com/google/exposure-notifications-server/internal/pb/export"
+	pubmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/timeutils"
+)
+
+type keyGenerator struct {
+	pos   int
+	count int8
+	mu    sync.Mutex
+}
+
+func (k *keyGenerator) fakeExposureKey(t testing.TB) []byte {
+	t.Helper()
+	k.mu.Lock()
+	defer k.mu.Unlock()
+
+	k.count++
+	if k.count < 0 {
+		k.count = 0
+		k.pos++
+		if k.pos >= 15 {
+			t.Fatal("overflow, too many keys generated")
+		}
+	}
+
+	key := make([]byte, 16)
+	key[k.pos] = byte(k.count)
+	return key
+}
+
+func TestTransform(t *testing.T) {
+	ctx := context.Background()
+	logger := logging.FromContext(ctx)
+
+	gen := &keyGenerator{}
+
+	// batch time, one hour after midnight.
+	// Will be used as the offset when creating TEKs.
+	batchTime := timeutils.UTCMidnight(time.Now().UTC()).Add(time.Hour)
+
+	sameDay := timeutils.UTCMidnight(batchTime)
+
+	input := []*exportproto.TemporaryExposureKey{
+		{ // Valid key, fully expired, missing report type and transmission risk
+			KeyData:                    gen.fakeExposureKey(t),
+			RollingStartIntervalNumber: proto.Int32(pubmodel.IntervalNumber(sameDay.Add(-24 * time.Hour))),
+			RollingPeriod:              proto.Int32(144),
+		},
+		{ // same day key, should be future dated
+			KeyData:                    gen.fakeExposureKey(t),
+			RollingStartIntervalNumber: proto.Int32(pubmodel.IntervalNumber(sameDay)),
+			RollingPeriod:              proto.Int32(144),
+		},
+	}
+
+	appPackageName := "import-export-test"
+	regions := []string{"US"}
+	settings := &transformer{
+		appPackageName: appPackageName,
+		importRegions:  regions,
+		batchTime:      batchTime,
+		truncateWindow: time.Hour,
+		exportImportID: 7,
+		importFileID:   42,
+		exportImportConfig: &pubmodel.ExportImportConfig{
+			DefaultReportType:         verifyapi.ReportTypeConfirmed,
+			BackfillSymptomOnset:      true,
+			BackfillSymptomOnsetValue: 10,
+			MaxSymptomOnsetDays:       28,
+			AllowClinical:             false,
+			AllowRevoked:              false,
+		},
+		logger: logger,
+	}
+
+	got, _ := settings.transform(input)
+
+	want := []*pubmodel.Exposure{
+		{
+			ExposureKey:           input[0].KeyData,
+			TransmissionRisk:      2,
+			AppPackageName:        appPackageName,
+			Regions:               regions,
+			IntervalNumber:        input[0].GetRollingStartIntervalNumber(),
+			IntervalCount:         input[0].GetRollingPeriod(),
+			CreatedAt:             batchTime,
+			ExportImportID:        &settings.exportImportID,
+			ImportFileID:          &settings.importFileID,
+			ReportType:            verifyapi.ReportTypeConfirmed,
+			DaysSinceSymptomOnset: &settings.exportImportConfig.BackfillSymptomOnsetValue,
+		},
+		{ // Key hasn't yet expired, so it is future dated into tomorrow.
+			ExposureKey:           input[1].KeyData,
+			TransmissionRisk:      2,
+			AppPackageName:        appPackageName,
+			Regions:               regions,
+			IntervalNumber:        input[1].GetRollingStartIntervalNumber(),
+			IntervalCount:         input[1].GetRollingPeriod(),
+			CreatedAt:             timeutils.UTCMidnight(batchTime.Add(24 * time.Hour)).Add(settings.truncateWindow),
+			ExportImportID:        &settings.exportImportID,
+			ImportFileID:          &settings.importFileID,
+			ReportType:            verifyapi.ReportTypeConfirmed,
+			DaysSinceSymptomOnset: &settings.exportImportConfig.BackfillSymptomOnsetValue,
+		},
+	}
+
+	if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(pubmodel.Exposure{})); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index b5fb96d..10d5d5e 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"reflect"
 	"sort"
@@ -1290,6 +1291,65 @@ func TestTransformOverlapping(t *testing.T) {
 	}
 }
 
+func TestExposure_HasDaysSinceSymptomOnset(t *testing.T) {
+	e := &Exposure{}
+
+	if e.HasDaysSinceSymptomOnset() {
+		t.Error("has days since onset before being set")
+	}
+	e.SetDaysSinceSymptomOnset(2)
+	if !e.HasDaysSinceSymptomOnset() {
+		t.Error("doesn't have days since onset after being set")
+	}
+}
+
+func TestExposure_HasDaysHealthAuthorityID(t *testing.T) {
+	e := &Exposure{}
+
+	if e.HasHealthAuthorityID() {
+		t.Error("has health authority id before being set")
+	}
+	e.SetHealthAuthorityID(2)
+	if !e.HasHealthAuthorityID() {
+		t.Error("doesn't have health authority id after being set")
+	}
+}
+
+func TestExposureRevisionFields(t *testing.T) {
+	revTime := time.Now().UTC().Truncate(time.Minute)
+	revType := "revisedType"
+
+	e := &Exposure{}
+	if e.HasBeenRevised() {
+		t.Error("reports revised before revisedAt is set")
+	}
+	e.SetRevisedAt(revTime)
+	e.SetRevisedReportType(revType)
+	e.SetRevisedDaysSinceSymptomOnset(5)
+	e.SetRevisedTransmissionRisk(2)
+
+	if !e.HasBeenRevised() {
+		t.Error("reports not revised after revisedAt set")
+	}
+
+	if revTime != *e.RevisedAt {
+		t.Errorf("revisedAt mismatch, want: %v got: %v", revTime, *e.RevisedAt)
+	}
+	if revType != *e.RevisedReportType {
+		t.Errorf("revised report type mismatch, want: %v got: %v", revTime, *e.RevisedAt)
+	}
+	if *e.RevisedDaysSinceSymptomOnset != 5 {
+		t.Errorf("revised days since onset mismatch, want: %v got: %v", revTime, *e.RevisedAt)
+	}
+	if *e.RevisedTransmissionRisk != 2 {
+		t.Errorf("revised transmission risk mismatch, want: %v got: %v", revTime, *e.RevisedAt)
+	}
+
+	if err := e.SetRevisedAt(revTime); err == nil {
+		t.Fatalf("expected error when revising already revised key")
+	}
+}
+
 func TestDaysFromSymptomOnset(t *testing.T) {
 	// Node that everything is based on midnight UTC so we'll start there.
 	now := timeutils.UTCMidnight(time.Now().UTC())
@@ -1360,11 +1420,100 @@ func TestDaysFromSymptomOnset(t *testing.T) {
 	}
 }
 
+func TestReviseKeys_FromFederation(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name     string
+		existing *Exposure
+		incoming *Exposure
+		err      error
+	}{
+		{
+			name: "export_import_mismatch",
+			existing: &Exposure{
+				ExposureKey:     []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				LocalProvenance: false,
+				ExportImportID:  proto.Int64(2),
+			},
+			incoming: &Exposure{
+				ExposureKey:    []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				ReportType:     "fake",
+				ExportImportID: proto.Int64(4),
+			},
+			err: ErrorNotSameFederationSource,
+		},
+		{
+			name: "export_import_same",
+			existing: &Exposure{
+				ExposureKey:     []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				LocalProvenance: false,
+				ReportType:      "likely",
+				ExportImportID:  proto.Int64(2),
+			},
+			incoming: &Exposure{
+				ExposureKey:    []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				ReportType:     "confirmed",
+				ExportImportID: proto.Int64(2),
+			},
+		},
+		{
+			name: "federation_mismatch",
+			existing: &Exposure{
+				ExposureKey:       []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				LocalProvenance:   false,
+				FederationQueryID: "foo",
+			},
+			incoming: &Exposure{
+				ExposureKey: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				ReportType:  "fake",
+			},
+			err: ErrorNotSameFederationSource,
+		},
+		{
+			name: "federation_mismatch",
+			existing: &Exposure{
+				ExposureKey:       []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				LocalProvenance:   false,
+				ReportType:        "likely",
+				FederationQueryID: "foo",
+			},
+			incoming: &Exposure{
+				ExposureKey:       []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
+				ReportType:        "confirmed",
+				FederationQueryID: "foo",
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+			existing := make(map[string]*Exposure)
+			existing[tc.existing.ExposureKeyBase64()] = tc.existing
+
+			incoming := make([]*Exposure, 1)
+			incoming[0] = tc.incoming
+
+			_, err := ReviseKeys(ctx, existing, incoming)
+			if err == nil && tc.err != nil {
+				t.Errorf("missing expected error: %v", tc.err)
+			} else if err != nil && tc.err == nil {
+				t.Errorf("unexpected error: %v", err)
+			} else if !errors.Is(err, tc.err) {
+				t.Errorf("wrong error, want: %v got: %v", tc.err, err)
+			}
+		})
+	}
+}
+
 func TestReviseKeys(t *testing.T) {
+	t.Parallel()
+
 	createdAt := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Hour)
 	revisedAt := time.Now().UTC().Truncate(time.Hour)
 
-	allExposures := make([]*Exposure, 4)
+	allExposures := make([]*Exposure, 5)
 	// The "existing" key that isn't in the revision set.
 	allExposures[0] = &Exposure{
 		ExposureKey: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
@@ -1405,15 +1554,29 @@ func TestReviseKeys(t *testing.T) {
 		HealthAuthorityID: int64Ptr(2),
 		ReportType:        verifyapi.ReportTypeConfirmed,
 	}
+	// key that will be presented again, but with no changes.
+	allExposures[4] = &Exposure{
+		ExposureKey:       []byte{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3},
+		TransmissionRisk:  0,
+		Regions:           []string{"US"},
+		IntervalNumber:    100,
+		IntervalCount:     144,
+		CreatedAt:         createdAt,
+		LocalProvenance:   true,
+		HealthAuthorityID: int64Ptr(2),
+		ReportType:        verifyapi.ReportTypeConfirmed,
+	}
 
 	ctx := context.Background()
 	existing := make(map[string]*Exposure)
 	existing[allExposures[0].ExposureKeyBase64()] = allExposures[0]
 	existing[allExposures[1].ExposureKeyBase64()] = allExposures[1]
+	existing[allExposures[4].ExposureKeyBase64()] = allExposures[4]
 
-	incoming := make([]*Exposure, 2)
+	incoming := make([]*Exposure, 3)
 	incoming[0] = allExposures[2]
 	incoming[1] = allExposures[3]
+	incoming[2] = allExposures[4]
 
 	got, err := ReviseKeys(ctx, existing, incoming)
 	if err != nil {
@@ -1524,6 +1687,17 @@ func TestExposureReview(t *testing.T) {
 			needsRevision: false,
 			err:           `invalid report type transition: cannot transition from "" to "likely"`,
 		},
+		{
+			name: "key_mismatch",
+			previous: &Exposure{
+				ExposureKey: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
+			},
+			incoming: &Exposure{
+				ExposureKey: []byte{42, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
+			},
+			needsRevision: false,
+			err:           `attempted to revise a key with a different key`,
+		},
 		{
 			name: "valid_transition_from_empty_report_type",
 			previous: &Exposure{
@@ -1656,10 +1830,11 @@ func TestExposureFromExportFile(t *testing.T) {
 	}
 
 	cases := []struct {
-		name      string
-		key       *export.TemporaryExposureKey
-		want      *Exposure
-		wantError string
+		name         string
+		key          *export.TemporaryExposureKey
+		want         *Exposure
+		modifyConfig func(*ExportImportConfig) *ExportImportConfig
+		wantError    string
 	}{
 		{
 			name: "valid_key",
@@ -1729,6 +1904,26 @@ func TestExposureFromExportFile(t *testing.T) {
 			},
 			wantError: "saw revoked key when not allowed",
 		},
+		{
+			name: "no_clinical",
+			key: &export.TemporaryExposureKey{
+				KeyData:                    validTEK,
+				RollingStartIntervalNumber: proto.Int32(validInterval),
+				RollingPeriod:              proto.Int32(verifyapi.MaxIntervalCount),
+				ReportType:                 export.TemporaryExposureKey_CONFIRMED_CLINICAL_DIAGNOSIS.Enum(),
+			},
+			modifyConfig: func(c *ExportImportConfig) *ExportImportConfig {
+				return &ExportImportConfig{
+					DefaultReportType:         c.DefaultReportType,
+					BackfillSymptomOnset:      c.BackfillSymptomOnset,
+					BackfillSymptomOnsetValue: c.BackfillSymptomOnsetValue,
+					MaxSymptomOnsetDays:       c.MaxSymptomOnsetDays,
+					AllowClinical:             false,
+					AllowRevoked:              false,
+				}
+			},
+			wantError: "saw likely key when not allowed",
+		},
 		{
 			name: "backfill_data_from_unknown",
 			key: &export.TemporaryExposureKey{
@@ -1864,7 +2059,12 @@ func TestExposureFromExportFile(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			got, err := FromExportKey(tc.key, config)
+			thisConfig := config
+			if tc.modifyConfig != nil {
+				thisConfig = tc.modifyConfig(config)
+			}
+
+			got, err := FromExportKey(tc.key, thisConfig)
 
 			if err != nil && tc.wantError == "" {
 				t.Fatalf("unexpected error: %v", err)