internal/gcp/crproxy: add simple path auth

Neither App Engine nor IAP provides a way to authorize a user for a
particular path. This CL provides a simple path-based auth implementation.

Details of the design are in the comment at the top of auth.go.

The mapping from users and paths to roles is stored in Firestore's
default DB. We need to maintain our own mapping because it isn't
feasible get roles from IAM. We can query the policy bindings, but we
can't expand groups.

Change-Id: I24a47dd8f0d5be927ada60ccad1c39ce4cd38948
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/610615
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gcp/crproxy/README.md b/internal/gcp/crproxy/README.md
index 197edae..5885fbe 100644
--- a/internal/gcp/crproxy/README.md
+++ b/internal/gcp/crproxy/README.md
@@ -14,3 +14,29 @@
 
 Use [gcloud's dev_appserver.py](https://cloud.google.com/appengine/docs/standard/tools/using-local-server?tab=go)
 or deploy to a separate environment.
+
+## Auth
+
+Authentication is done by the IAP (Identity-Aware Proxy). It should be restricted
+to members who need to access the Cloud Run service.
+
+Since IAP does not allow fine-grained access control, this app implements a simple
+path-based authorization scheme described in auth.go. The ACLs for this scheme are
+stored in the default Firestore database in the auth collection.
+Modify the auth mappings when the set of users or URL paths changes.
+
+When a user is added or removed, edit the "auth/users" Firestore document.
+Adding a user involves adding a new field with the user's email address.
+The value of the field is the user's role:
+
+- "reader" for read-only access.
+- "writer" for the ability to make changes, like setting the log level.
+- "admin" for access to all paths.
+
+The default is "reader", so users with reader access don't need to be added.
+
+Adding a path involves editing the "auth/paths" Firestore document.
+The new field's name is the path, and the value is the minimum user role
+that can access the path. The default minimum is "admin". Use "reader" for
+paths that only read information, "writer" for paths that change the service's
+state, and "admin" for paths that make serious changes.
diff --git a/internal/gcp/crproxy/auth.go b/internal/gcp/crproxy/auth.go
new file mode 100644
index 0000000..1a9da8e
--- /dev/null
+++ b/internal/gcp/crproxy/auth.go
@@ -0,0 +1,94 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+/*
+This file implements a simple authorization scheme.
+
+Users are associated with "roles" that grant them access to URL paths.
+The roles are unrelated to IAM roles.
+There are three roles, ordered with respect to the access they grant:
+
+    admin > writer > reader
+
+A URL path is also associated with a role: the minimum user role that
+can access the path.
+
+For example, a user with writer role can access paths with reader and writer
+roles, but not a path with admin role.
+
+If a user is missing from the mapping, it is given the reader role, on the
+assumption that IAP will block all users that are unauthorized.
+
+The role mappings are stored in Firestore, access to which is controlled
+by IAM. Anyone with the Cloud Datastore User role can edit the mappings.
+*/
+
+import (
+	"cmp"
+	"context"
+	"errors"
+
+	"cloud.google.com/go/firestore"
+)
+
+// A role summarizes the permissions of a user.
+type role string
+
+const (
+	reader = "reader"
+	writer = "writer"
+	admin  = "admin"
+)
+
+// roleRanks maps from a role to its rank.
+// Higher-rank roles include (grant all the access of) lower ones.
+var roleRanks = map[role]int{
+	reader: 1,
+	writer: 2,
+	admin:  3,
+}
+
+// isAuthorized reports whether a user is authorized to access a URL.
+// Access is based solely on the URL's path.
+// The userRoles argument maps users to the roles they have.
+// The pathRoles argument maps paths to the minimum roles they require.
+// A user is authorized to access a path if its role's rank is higher than
+// the minimum that the path requires.
+//
+// If a user is missing, it is assigned the lowest-rank role.
+// If a path is missing, it is assigned the highest-rank role.
+// This ensures that auth "fails closed": missing data results in denied access.
+func isAuthorized(user, urlPath string, userRoles, pathRoles map[string]role) bool {
+	userRole := cmp.Or(userRoles[user], reader)
+	pathRole := cmp.Or(pathRoles[urlPath], admin)
+	return includesRole(userRole, pathRole)
+}
+
+// includesRole reports whether r1 includes (is of higher rank than) r2.
+func includesRole(r1, r2 role) bool {
+	return roleRanks[r1] >= roleRanks[r2]
+}
+
+// readFirestoreRoles reads role maps from Firestore.
+// Firestore stores role maps in two documents, "auth/users" and "auth/paths".
+func readFirestoreRoles(ctx context.Context, c *firestore.Client) (userRoles, pathRoles map[string]role, err error) {
+	coll := c.Collection("auth")
+	err = errors.Join(
+		decodeRoleMap(ctx, coll.Doc("users"), &userRoles),
+		decodeRoleMap(ctx, coll.Doc("paths"), &pathRoles))
+	if err != nil {
+		return nil, nil, err
+	}
+	return userRoles, pathRoles, nil
+}
+
+func decodeRoleMap(ctx context.Context, dr *firestore.DocumentRef, mp *map[string]role) error {
+	ds, err := dr.Get(ctx)
+	if err != nil {
+		return err
+	}
+	return ds.DataTo(mp)
+}
diff --git a/internal/gcp/crproxy/main.go b/internal/gcp/crproxy/main.go
index 5a15c2f..9c48c4a 100644
--- a/internal/gcp/crproxy/main.go
+++ b/internal/gcp/crproxy/main.go
@@ -23,6 +23,7 @@
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -31,6 +32,8 @@
 	"os"
 	"time"
 
+	"cloud.google.com/go/compute/metadata"
+	"cloud.google.com/go/firestore"
 	"golang.org/x/oscar/internal/gcp/gcphandler"
 	"google.golang.org/api/idtoken"
 )
@@ -58,7 +61,8 @@
 	// This App Engine app's service account should have the Cloud Run Invoker role.
 	// The only other piece of the credential is the audience value for the Cloud Run service,
 	// which is just its URL.
-	idClient, err := idtoken.NewClient(context.Background(), "https://"+cloudRunHost)
+	ctx := context.Background()
+	idClient, err := idtoken.NewClient(ctx, "https://"+cloudRunHost)
 	if err != nil {
 		lg.Error("idtoken.NewClient", "err", err)
 		os.Exit(2)
@@ -68,6 +72,29 @@
 		Host:   cloudRunHost,
 	}
 
+	// Create a Firestore client for reading auth information.
+	fsClient, err := firestoreClient(ctx, "")
+	if err != nil {
+		lg.Error("firestore.NewClient", "err", err)
+		os.Exit(2)
+	}
+	defer fsClient.Close()
+
+	// Read the role maps at startup to check if they are valid.
+	if _, _, err := readFirestoreRoles(ctx, fsClient); err != nil {
+		lg.Error("readRoles", "err", err)
+		os.Exit(1)
+	}
+
+	authFunc := func(user, urlPath string) (bool, error) {
+		// Re-Read the role maps each time in case they've been updated.
+		userMap, pathMap, err := readFirestoreRoles(ctx, fsClient)
+		if err != nil {
+			return false, err
+		}
+		return isAuthorized(user, urlPath, userMap, pathMap), nil
+	}
+
 	// Create a reverse proxy to the Cloud Run host.
 	rp := &httputil.ReverseProxy{
 		Rewrite: func(r *httputil.ProxyRequest) {
@@ -80,17 +107,18 @@
 	}
 
 	mux := http.NewServeMux()
-	mux.Handle("/", iapAuth(lg, jwtAudience, rp))
+	mux.Handle("/", iapAuth(lg, jwtAudience, authFunc, rp))
 	lg.Info("listening", "port", port)
 	lg.Error("ListenAndServe", "err", http.ListenAndServe(":"+port, mux))
 	os.Exit(1)
 }
 
-// Copied with minor modifications from from x/website/cmd/adminapp/main.go.
-// It shouldn't be necessary to perform this extra check on App Engine,
-// but it can't hurt.
-func iapAuth(lg *slog.Logger, audience string, h http.Handler) http.Handler {
-	// https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload
+// iapAuth validates the JWT token passed by IAP.
+// This is required to secure the app. See https://cloud.google.com/iap/docs/identity-howto.
+//
+// Based on x/website/cmd/adminapp/main.go.
+func iapAuth(lg *slog.Logger, audience string, authorized func(string, string) (bool, error), h http.Handler) http.Handler {
+	// See https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload.
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		jwt := r.Header.Get("x-goog-iap-jwt-assertion")
 		if jwt == "" {
@@ -98,25 +126,61 @@
 			fmt.Fprintf(w, "must run under IAP\n")
 			return
 		}
-
-		payload, err := idtoken.Validate(r.Context(), jwt, audience)
+		user, err := validateJWT(r.Context(), jwt, audience)
 		if err != nil {
-			w.WriteHeader(http.StatusUnauthorized)
-			lg.Warn("JWT validation error", "err", err)
+			http.Error(w, err.Error(), http.StatusUnauthorized)
+			lg.Warn("IAP validation", "err", err)
 			return
 		}
-		if payload.Issuer != "https://cloud.google.com/iap" {
-			w.WriteHeader(http.StatusUnauthorized)
-			lg.Warn("Incorrect issuer", "issuer", payload.Issuer)
+		auth, err := authorized(user, r.URL.Path)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			lg.Error("authorizing", "err", err)
 			return
 		}
-		if payload.Expires+30 < time.Now().Unix() || payload.IssuedAt-30 > time.Now().Unix() {
-			w.WriteHeader(http.StatusUnauthorized)
-			lg.Warn("Bad JWT times",
-				"expires", time.Unix(payload.Expires, 0),
-				"issued", time.Unix(payload.IssuedAt, 0))
+		if !auth {
+			http.Error(w, "ACLs forbid access", http.StatusUnauthorized)
 			return
 		}
 		h.ServeHTTP(w, r)
 	})
 }
+
+func firestoreClient(ctx context.Context, projectID string) (*firestore.Client, error) {
+	if projectID == "" {
+		var err error
+		projectID, err = metadata.ProjectIDWithContext(ctx)
+		if err != nil {
+			return nil, err
+		}
+		if projectID == "" {
+			return nil, errors.New("metadata.ProjectID is empty")
+		}
+	}
+	return firestore.NewClient(ctx, projectID)
+}
+
+// validateJWT validates a JWT token.
+// It also checks that IAP issued the token, that its lifetime is valid
+// (it was issued before, and expires after, the current time, with some slack),
+// and that it contains a valid "email" claim.
+func validateJWT(ctx context.Context, jwt, audience string) (string, error) {
+	payload, err := idtoken.Validate(ctx, jwt, audience)
+	if err != nil {
+		return "", fmt.Errorf("idtoken.Validate: %v", err)
+	}
+	if payload.Issuer != "https://cloud.google.com/iap" {
+		return "", fmt.Errorf("incorrect issuer: %q", payload.Issuer)
+	}
+	if payload.Expires+30 < time.Now().Unix() || payload.IssuedAt-30 > time.Now().Unix() {
+		return "", errors.New("bad JWT token times")
+	}
+	user, ok := payload.Claims["email"].(string)
+	if !ok {
+		return "", errors.New("'email' claim not a string")
+	}
+	if user == "" {
+		return "", errors.New("JWT missing 'email' claim")
+	}
+	return user, nil
+}
diff --git a/internal/gcp/crproxy/main_test.go b/internal/gcp/crproxy/main_test.go
new file mode 100644
index 0000000..7e46546
--- /dev/null
+++ b/internal/gcp/crproxy/main_test.go
@@ -0,0 +1,64 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"context"
+	"flag"
+	"testing"
+)
+
+var projectID = flag.String("project", "", "project ID")
+
+func TestIsAuthorized(t *testing.T) {
+	userMap := map[string]role{
+		"R": reader,
+		"W": writer,
+		"A": admin,
+	}
+	pathMap := map[string]role{
+		"/r": reader,
+		"/w": writer,
+		"/a": admin,
+	}
+
+	for _, test := range []struct {
+		user, path string
+		want       bool
+	}{
+		{"R", "/r", true},
+		{"R", "/w", false},
+		{"W", "/a", false},
+		{"W", "/r", true},
+		{"X", "/r", true}, // every user is a reader
+		{"X", "/w", false},
+		{"X", "/x", false}, // unknown paths are admin
+		{"W", "/x", false},
+		{"A", "/x", true},
+	} {
+		got := isAuthorized(test.user, test.path, userMap, pathMap)
+		if got != test.want {
+			t.Errorf("user %s, path %s: got %t, want %t", test.user, test.path, got, test.want)
+		}
+	}
+}
+
+func TestFirestoreAuth(t *testing.T) {
+	if *projectID == "" {
+		t.Skip("no project ID")
+	}
+	ctx := context.Background()
+	c, err := firestoreClient(ctx, *projectID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer c.Close()
+	um, pm, err := readFirestoreRoles(ctx, c)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("user map: %+v", um)
+	t.Logf("path map: %+v", pm)
+}