services/identity: Support email-domain based classification of
blessings.

Motivation:
We want the ability to use a single identity provider but be able to ACL
different classes of users based on their email address. For example,
all @google.com email addresses should be able to user some services
that @gmail.com addresses cannot.

There are many ways to go about doing this - from using different
identity providers, to issuing dual blessings and what not.  For now, we
try this simple approach - given an email address, it can be classified
based on the domain. For example, with:

--email_classifier=google.com=internal

identityd running as say "dev.v.io" will issue the blessing:

- dev.v.io/users/alice@gmail.com to alice@gmail.com
- dev.v.io/internal/bob@google.com to bob@google.com

Thus, an ACL of the form:
{ In: "dev.v.io/internal" }
will grant access to "dev.v.io/internal/bob@google.com" but
deny access to "dev.v.io/users/alice@gmail.com".

One can argue that bob@google.com should be blessed as both:
dev.v.io/users/bob@google.com
&
dev.v.io/internal/bob@google.com
and we might do that in the future.

This is just a quick change for now.

Change-Id: If967a0ee849131634371069ddbc4ed8afc80eeaf
diff --git a/lib/modules/core/test_identityd.go b/lib/modules/core/test_identityd.go
index 793824a..87fe38a 100644
--- a/lib/modules/core/test_identityd.go
+++ b/lib/modules/core/test_identityd.go
@@ -22,10 +22,9 @@
 )
 
 var (
-	googleDomain = flag.CommandLine.String("google_domain", "", "An optional domain name. When set, only email addresses from this domain are allowed to authenticate via Google OAuth")
-	host         = flag.CommandLine.String("host", "localhost", "Hostname the HTTP server listens on. This can be the name of the host running the webserver, but if running behind a NAT or load balancer, this should be the host name that clients will connect to. For example, if set to 'x.com', Veyron identities will have the IssuerName set to 'x.com' and clients can expect to find the root name and public key of the signer at 'x.com/blessing-root'.")
-	httpaddr     = flag.CommandLine.String("httpaddr", "localhost:0", "Address on which the HTTP server listens on.")
-	tlsconfig    = flag.CommandLine.String("tlsconfig", "", "Comma-separated list of TLS certificate and private key files. This must be provided.")
+	host      = flag.CommandLine.String("host", "localhost", "Hostname the HTTP server listens on. This can be the name of the host running the webserver, but if running behind a NAT or load balancer, this should be the host name that clients will connect to. For example, if set to 'x.com', Veyron identities will have the IssuerName set to 'x.com' and clients can expect to find the root name and public key of the signer at 'x.com/blessing-root'.")
+	httpaddr  = flag.CommandLine.String("httpaddr", "localhost:0", "Address on which the HTTP server listens on.")
+	tlsconfig = flag.CommandLine.String("tlsconfig", "", "Comma-separated list of TLS certificate and private key files. This must be provided.")
 )
 
 func init() {
@@ -68,7 +67,6 @@
 	params := blesser.OAuthBlesserParams{
 		OAuthProvider:     oauthProvider,
 		BlessingDuration:  duration,
-		DomainRestriction: *googleDomain,
 		RevocationManager: revocationManager,
 	}
 
@@ -78,7 +76,8 @@
 		reader,
 		revocationManager,
 		params,
-		caveats.NewMockCaveatSelector())
+		caveats.NewMockCaveatSelector(),
+		nil)
 
 	l := veyron2.GetListenSpec(ctx)
 
diff --git a/services/identity/blesser/oauth.go b/services/identity/blesser/oauth.go
index c11c0e5..504173a 100644
--- a/services/identity/blesser/oauth.go
+++ b/services/identity/blesser/oauth.go
@@ -8,6 +8,7 @@
 	"v.io/core/veyron/services/identity"
 	"v.io/core/veyron/services/identity/oauth"
 	"v.io/core/veyron/services/identity/revocation"
+	"v.io/core/veyron/services/identity/util"
 
 	"v.io/core/veyron2/ipc"
 	"v.io/core/veyron2/security"
@@ -18,7 +19,7 @@
 	authcodeClient     struct{ ID, Secret string }
 	accessTokenClients []oauth.AccessTokenClient
 	duration           time.Duration
-	domain             string
+	emailClassifier    *util.EmailClassifier
 	dischargerLocation string
 	revocationManager  revocation.RevocationManager
 }
@@ -29,8 +30,8 @@
 	OAuthProvider oauth.OAuthProvider
 	// The OAuth client IDs and names for the clients of the BlessUsingAccessToken RPCs.
 	AccessTokenClients []oauth.AccessTokenClient
-	// If non-empty, only email addresses from this domain will be blessed.
-	DomainRestriction string
+	// Determines prefixes used for blessing extensions based on email address.
+	EmailClassifier *util.EmailClassifier
 	// The object name of the discharger service. If this is empty then revocation caveats will not be granted.
 	DischargerLocation string
 	// The revocation manager that generates caveats and manages revocation.
@@ -45,13 +46,12 @@
 //
 // Blessings generated by this service carry a third-party revocation caveat if a
 // RevocationManager is specified by the params or they carry an ExpiryCaveat that
-// expires after the duration specified by the params. If domain is non-empty, then
-// blessings are generated only for email addresses from that domain.
+// expires after the duration specified by the params.
 func NewOAuthBlesserServer(p OAuthBlesserParams) identity.OAuthBlesserServerStub {
 	return identity.OAuthBlesserServer(&oauthBlesser{
 		oauthProvider:      p.OAuthProvider,
 		duration:           p.BlessingDuration,
-		domain:             p.DomainRestriction,
+		emailClassifier:    p.EmailClassifier,
 		dischargerLocation: p.DischargerLocation,
 		revocationManager:  p.RevocationManager,
 		accessTokenClients: p.AccessTokenClients,
@@ -69,15 +69,6 @@
 
 func (b *oauthBlesser) bless(ctx ipc.ServerContext, email, clientName string) (security.WireBlessings, string, error) {
 	var noblessings security.WireBlessings
-	if len(b.domain) > 0 && !strings.HasSuffix(email, "@"+b.domain) {
-		return noblessings, "", fmt.Errorf("domain restrictions preclude blessings for %q", email)
-	}
-	// Append clientName (e.g., "android", "chrome") to the email and then bless under that.
-	// Since blessings issued by this process do not have many caveats on them and typically
-	// have a large expiry duration, we include the clientName in the extension so that
-	// servers can explicitly distinguish these clients while specifying authorization policies
-	// (say, via ACLs).
-	extension := email + security.ChainSeparator + clientName
 	self := ctx.LocalPrincipal()
 	if self == nil {
 		return noblessings, "", fmt.Errorf("server error: no authentication happened")
@@ -92,6 +83,16 @@
 	if err != nil {
 		return noblessings, "", err
 	}
+	extension := strings.Join([]string{
+		b.emailClassifier.Classify(email),
+		email,
+		// Append clientName (e.g., "android", "chrome") to the email and then bless under that.
+		// Since blessings issued by this process do not have many caveats on them and typically
+		// have a large expiry duration, we include the clientName in the extension so that
+		// servers can explicitly distinguish these clients while specifying authorization policies
+		// (say, via ACLs).
+		clientName,
+	}, security.ChainSeparator)
 	blessing, err := self.Bless(ctx.RemoteBlessings().PublicKey(), ctx.LocalBlessings(), extension, caveat)
 	if err != nil {
 		return noblessings, "", err
diff --git a/services/identity/blesser/oauth_test.go b/services/identity/blesser/oauth_test.go
index 4b20fd8..12c3edc 100644
--- a/services/identity/blesser/oauth_test.go
+++ b/services/identity/blesser/oauth_test.go
@@ -29,7 +29,7 @@
 		t.Errorf("BlessUsingAccessToken failed: %v", err)
 	}
 
-	wantExtension := oauth.MockEmail + security.ChainSeparator + oauth.MockClient
+	wantExtension := "users" + security.ChainSeparator + oauth.MockEmail + security.ChainSeparator + oauth.MockClient
 	if extension != wantExtension {
 		t.Errorf("got extension: %s, want: %s", extension, wantExtension)
 	}
diff --git a/services/identity/identityd/main.go b/services/identity/identityd/main.go
index ebdf5b6..bd1fa92 100644
--- a/services/identity/identityd/main.go
+++ b/services/identity/identityd/main.go
@@ -22,6 +22,7 @@
 	"v.io/core/veyron/services/identity/oauth"
 	"v.io/core/veyron/services/identity/revocation"
 	"v.io/core/veyron/services/identity/server"
+	"v.io/core/veyron/services/identity/util"
 )
 
 var (
@@ -32,7 +33,7 @@
 	googleConfigWeb     = flag.String("google_config_web", "", "Path to JSON-encoded OAuth client configuration for the web application that renders the audit log for blessings provided by this provider.")
 	googleConfigChrome  = flag.String("google_config_chrome", "", "Path to the JSON-encoded OAuth client configuration for Chrome browser applications that obtain blessings from this server (via the OAuthBlesser.BlessUsingAccessToken RPC) from this server.")
 	googleConfigAndroid = flag.String("google_config_android", "", "Path to the JSON-encoded OAuth client configuration for Android applications that obtain blessings from this server (via the OAuthBlesser.BlessUsingAccessToken RPC) from this server.")
-	googleDomain        = flag.String("google_domain", "", "An optional domain name. When set, only email addresses from this domain are allowed to authenticate via Google OAuth")
+	emailClassifier     util.EmailClassifier
 
 	// Flags controlling the HTTP server
 	host      = flag.String("host", defaultHost(), "Hostname the HTTP server listens on. This can be the name of the host running the webserver, but if running behind a NAT or load balancer, this should be the host name that clients will connect to. For example, if set to 'x.com', Veyron identities will have the IssuerName set to 'x.com' and clients can expect to find the root name and public key of the signer at 'x.com/blessing-root'.")
@@ -41,6 +42,7 @@
 )
 
 func main() {
+	flag.Var(&emailClassifier, "email_classifier", "A comma-separated list of <domain>=<prefix> pairs. For example 'google.com=internal,v.io=trusted'. When specified, then the blessings generated for email address of <domain> will use the extension <prefix>/<email> instead of the default extension of users/<email>.")
 	flag.Usage = usage
 	flag.Parse()
 
@@ -57,7 +59,7 @@
 		}
 	}
 
-	googleoauth, err := oauth.NewGoogleOAuth(*googleConfigWeb, *googleDomain)
+	googleoauth, err := oauth.NewGoogleOAuth(*googleConfigWeb)
 	if err != nil {
 		vlog.Fatalf("Failed to setup GoogleOAuth: %v", err)
 	}
@@ -82,7 +84,8 @@
 		reader,
 		revocationManager,
 		googleOAuthBlesserParams(googleoauth, revocationManager),
-		caveats.NewBrowserCaveatSelector())
+		caveats.NewBrowserCaveatSelector(),
+		&emailClassifier)
 	s.Serve(ctx, &listenSpec, *host, *httpaddr, *tlsconfig)
 }
 
@@ -108,7 +111,7 @@
 	params := blesser.OAuthBlesserParams{
 		OAuthProvider:     oauthProvider,
 		BlessingDuration:  365 * 24 * time.Hour,
-		DomainRestriction: *googleDomain,
+		EmailClassifier:   &emailClassifier,
 		RevocationManager: revocationManager,
 	}
 	if clientID, err := getOAuthClientID(*googleConfigChrome); err != nil {
diff --git a/services/identity/identityd_test/main.go b/services/identity/identityd_test/main.go
index d9c8a23..72dfa70 100644
--- a/services/identity/identityd_test/main.go
+++ b/services/identity/identityd_test/main.go
@@ -21,8 +21,6 @@
 )
 
 var (
-	googleDomain = flag.String("google_domain", "", "An optional domain name. When set, only email addresses from this domain are allowed to authenticate via Google OAuth")
-
 	// Flags controlling the HTTP server
 	host      = flag.String("host", "localhost", "Hostname the HTTP server listens on. This can be the name of the host running the webserver, but if running behind a NAT or load balancer, this should be the host name that clients will connect to. For example, if set to 'x.com', Veyron identities will have the IssuerName set to 'x.com' and clients can expect to find the root name and public key of the signer at 'x.com/blessing-root'.")
 	httpaddr  = flag.String("httpaddr", "localhost:8125", "Address on which the HTTP server listens on.")
@@ -54,7 +52,6 @@
 	params := blesser.OAuthBlesserParams{
 		OAuthProvider:     oauthProvider,
 		BlessingDuration:  duration,
-		DomainRestriction: *googleDomain,
 		RevocationManager: revocationManager,
 	}
 
@@ -68,7 +65,8 @@
 		reader,
 		revocationManager,
 		params,
-		caveats.NewMockCaveatSelector())
+		caveats.NewMockCaveatSelector(),
+		nil)
 	s.Serve(ctx, &listenSpec, *host, *httpaddr, *tlsconfig)
 }
 
diff --git a/services/identity/oauth/googleoauth.go b/services/identity/oauth/googleoauth.go
index 64baa4e..1cbf32c 100644
--- a/services/identity/oauth/googleoauth.go
+++ b/services/identity/oauth/googleoauth.go
@@ -6,7 +6,6 @@
 	"fmt"
 	"net/http"
 	"os"
-	"strings"
 
 	"v.io/core/veyron2/vlog"
 )
@@ -17,14 +16,13 @@
 	// Console for API access.
 	clientID, clientSecret   string
 	scope, authURL, tokenURL string
-	domain                   string
 	// URL used to verify google tokens.
 	// (From https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
 	// and https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken)
 	verifyURL string
 }
 
-func NewGoogleOAuth(configFile, domainRestriction string) (OAuthProvider, error) {
+func NewGoogleOAuth(configFile string) (OAuthProvider, error) {
 	clientID, clientSecret, err := getOAuthClientIDAndSecret(configFile)
 	if err != nil {
 		return nil, err
@@ -36,7 +34,6 @@
 		authURL:      "https://accounts.google.com/o/oauth2/auth",
 		tokenURL:     "https://accounts.google.com/o/oauth2/token",
 		verifyURL:    "https://www.googleapis.com/oauth2/v1/tokeninfo?",
-		domain:       domainRestriction,
 	}, nil
 }
 
@@ -86,10 +83,6 @@
 	if gtoken.Audience != config.ClientId {
 		return "", fmt.Errorf("unexpected audience(%v) in GoogleIDToken", gtoken.Audience)
 	}
-	if len(g.domain) > 0 && !strings.HasSuffix(gtoken.Email, "@"+g.domain) {
-		return "", fmt.Errorf("domain restrictions preclude %q from using this service", gtoken.Email)
-	}
-
 	return gtoken.Email, nil
 }
 
diff --git a/services/identity/oauth/handler.go b/services/identity/oauth/handler.go
index e341a93..6fd22e1 100644
--- a/services/identity/oauth/handler.go
+++ b/services/identity/oauth/handler.go
@@ -70,8 +70,10 @@
 	// MacaroonBlessingService is the object name to which macaroons create by this HTTP
 	// handler can be exchanged for a blessing.
 	MacaroonBlessingService string
-	// If non-empty, only email addressses from this domain will be blessed.
-	DomainRestriction string
+	// EmailClassifier is used to decide the prefix used for blessing extensions.
+	// For example, if EmailClassifier.Classify("foo@bar.com") returns "guests",
+	// then the email foo@bar.com will receive the blessing "guests/foo@bar.com".
+	EmailClassifier *util.EmailClassifier
 	// OAuthProvider is used to authenticate and get a blessee email.
 	OAuthProvider OAuthProvider
 	// CaveatSelector is used to obtain caveats from the user when seeking a blessing.
@@ -318,10 +320,6 @@
 		util.HTTPBadRequest(w, r, err)
 		return
 	}
-	if len(h.args.DomainRestriction) > 0 && !strings.HasSuffix(email, "@"+h.args.DomainRestriction) {
-		util.HTTPBadRequest(w, r, fmt.Errorf("blessings for name %q are not allowed due to domain restriction", email))
-		return
-	}
 	outputMacaroon, err := h.csrfCop.NewToken(w, r, clientIDCookie, addCaveatsMacaroon{
 		ToolRedirectURL: inputMacaroon.RedirectURL,
 		ToolState:       inputMacaroon.State,
@@ -355,9 +353,12 @@
 		util.HTTPBadRequest(w, r, fmt.Errorf("failed to create caveats: %v", err))
 		return
 	}
-	name := inputMacaroon.Email
+	parts := []string{
+		h.args.EmailClassifier.Classify(inputMacaroon.Email),
+		inputMacaroon.Email,
+	}
 	if len(blessingExtension) > 0 {
-		name = name + security.ChainSeparator + blessingExtension
+		parts = append(parts, blessingExtension)
 	}
 	if len(caveats) == 0 {
 		util.HTTPBadRequest(w, r, fmt.Errorf("server disallows attempts to bless with no caveats"))
@@ -366,7 +367,7 @@
 	m := BlessingMacaroon{
 		Creation: time.Now(),
 		Caveats:  caveats,
-		Name:     name,
+		Name:     strings.Join(parts, security.ChainSeparator),
 	}
 	macBytes, err := vom.Encode(m)
 	if err != nil {
diff --git a/services/identity/server/identityd.go b/services/identity/server/identityd.go
index 1aafdd1..b415f20 100644
--- a/services/identity/server/identityd.go
+++ b/services/identity/server/identityd.go
@@ -26,6 +26,7 @@
 	"v.io/core/veyron/services/identity/handlers"
 	"v.io/core/veyron/services/identity/oauth"
 	"v.io/core/veyron/services/identity/revocation"
+	"v.io/core/veyron/services/identity/util"
 	services "v.io/core/veyron/services/security"
 	"v.io/core/veyron/services/security/discharger"
 )
@@ -49,6 +50,7 @@
 	revocationManager  revocation.RevocationManager
 	oauthBlesserParams blesser.OAuthBlesserParams
 	caveatSelector     caveats.CaveatSelector
+	emailClassifier    *util.EmailClassifier
 }
 
 // NewIdentityServer returns a IdentityServer that:
@@ -56,7 +58,7 @@
 // - auditor and blessingLogReader to audit the root principal and read audit logs
 // - revocationManager to store revocation data and grant discharges
 // - oauthBlesserParams to configure the identity.OAuthBlesser service
-func NewIdentityServer(oauthProvider oauth.OAuthProvider, auditor audit.Auditor, blessingLogReader auditor.BlessingLogReader, revocationManager revocation.RevocationManager, oauthBlesserParams blesser.OAuthBlesserParams, caveatSelector caveats.CaveatSelector) *identityd {
+func NewIdentityServer(oauthProvider oauth.OAuthProvider, auditor audit.Auditor, blessingLogReader auditor.BlessingLogReader, revocationManager revocation.RevocationManager, oauthBlesserParams blesser.OAuthBlesserParams, caveatSelector caveats.CaveatSelector, emailClassifier *util.EmailClassifier) *identityd {
 	return &identityd{
 		oauthProvider,
 		auditor,
@@ -64,6 +66,7 @@
 		revocationManager,
 		oauthBlesserParams,
 		caveatSelector,
+		emailClassifier,
 	}
 }
 
@@ -107,6 +110,7 @@
 		MacaroonBlessingService: naming.JoinAddressName(published[0], macaroonService),
 		OAuthProvider:           s.oauthProvider,
 		CaveatSelector:          s.caveatSelector,
+		EmailClassifier:         s.emailClassifier,
 	})
 	if err != nil {
 		vlog.Fatalf("Failed to create HTTP handler for oauth authentication: %v", err)
diff --git a/services/identity/util/classify.go b/services/identity/util/classify.go
new file mode 100644
index 0000000..33e2e95
--- /dev/null
+++ b/services/identity/util/classify.go
@@ -0,0 +1,70 @@
+package util
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+)
+
+const defaultClass = "users"
+
+// EmailClassifier classifies/categorizes email addresses based on the domain.
+type EmailClassifier struct {
+	mu sync.RWMutex
+	m  map[string]string
+}
+
+// Classify returns the classification of email.
+func (c *EmailClassifier) Classify(email string) string {
+	if c == nil {
+		return defaultClass
+	}
+	parts := strings.Split(email, "@")
+	if len(parts) != 2 {
+		return defaultClass
+	}
+	domain := parts[1]
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	if class := c.m[domain]; len(class) > 0 {
+		return class
+	}
+	return defaultClass
+}
+
+// Set implements flag.Value.
+//
+// value should be a comma-separated list of <domain>=<class> pairs.
+func (c *EmailClassifier) Set(value string) error {
+	m := make(map[string]string)
+	for _, entry := range strings.Split(value, ",") {
+		pair := strings.Split(entry, "=")
+		if len(pair) != 2 {
+			return fmt.Errorf("invalid pair %q: must be in <domain>=<class> format", entry)
+		}
+		domain := strings.TrimSpace(pair[0])
+		class := strings.TrimSpace(pair[1])
+		if len(domain) == 0 {
+			return fmt.Errorf("empty domain in %q", entry)
+		}
+		if len(class) == 0 {
+			return fmt.Errorf("empty class in %q", entry)
+		}
+		m[domain] = class
+	}
+	c.mu.Lock()
+	c.m = m
+	c.mu.Unlock()
+	return nil
+}
+
+// Get implements flag.Getter.
+func (c *EmailClassifier) Get() interface{} {
+	return c
+}
+
+func (c *EmailClassifier) String() string {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return fmt.Sprintf("%v", c.m)
+}
diff --git a/services/identity/util/classify_test.go b/services/identity/util/classify_test.go
new file mode 100644
index 0000000..6077ee4
--- /dev/null
+++ b/services/identity/util/classify_test.go
@@ -0,0 +1,28 @@
+package util
+
+import (
+	"flag"
+	"testing"
+)
+
+func TestEmailClassifier(t *testing.T) {
+	fs := flag.NewFlagSet("TestEmailClassifier", flag.PanicOnError)
+	var c EmailClassifier
+	fs.Var(&c, "myflag", "my usage")
+	if err := fs.Parse([]string{"--myflag", "foo.com=internal,bar.com=external"}); err != nil {
+		t.Fatal(err)
+	}
+	tests := []struct {
+		in, out string
+	}{
+		{"batman@foo.com", "internal"},
+		{"bugsbunny@foo.com.com", "users"},
+		{"daffyduck@bar.com", "external"},
+		{"joker@other.com", "users"},
+	}
+	for _, test := range tests {
+		if got := c.Classify(test.in); got != test.out {
+			t.Errorf("%q: Got %q, want %q", test.in, got, test.out)
+		}
+	}
+}