m/n/core/curator: authenticated RPC

This adds authentication middleware (server interceptors) for gRPC
services running on the public curator listener.

Most of this code is testing harnesses to start up just the curator
listener with enough of a PKI infrastructure copy from a real Metropolis
cluster to be able to start running tests against GetRegisterTicket.

Change-Id: I429ff29e3c1233d74e8da619ddb543d56bc051b9
Reviewed-on: https://review.monogon.dev/c/monogon/+/311
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/node/core/cluster/BUILD.bazel b/metropolis/node/core/cluster/BUILD.bazel
index a68f63c..322a337 100644
--- a/metropolis/node/core/cluster/BUILD.bazel
+++ b/metropolis/node/core/cluster/BUILD.bazel
@@ -23,7 +23,6 @@
         "//metropolis/proto/api:go_default_library",
         "//metropolis/proto/common:go_default_library",
         "//metropolis/proto/private:go_default_library",
-        "@org_golang_google_grpc//credentials:go_default_library",
         "@org_golang_google_protobuf//proto:go_default_library",
     ],
 )
diff --git a/metropolis/node/core/cluster/node.go b/metropolis/node/core/cluster/node.go
index 0e3c29a..af5b654 100644
--- a/metropolis/node/core/cluster/node.go
+++ b/metropolis/node/core/cluster/node.go
@@ -7,8 +7,6 @@
 	"crypto/x509"
 	"fmt"
 
-	"google.golang.org/grpc/credentials"
-
 	"source.monogon.dev/metropolis/node/core/curator"
 	"source.monogon.dev/metropolis/node/core/localstorage"
 )
@@ -20,6 +18,12 @@
 	ca   *x509.Certificate
 }
 
+// ClusterCA returns the CA certificate of the cluster for which this
+// NodeCertificate is emitted.
+func (n *NodeCertificate) ClusterCA() *x509.Certificate {
+	return n.ca
+}
+
 // NodeCredentials are the public and private part of the credentials of a node.
 //
 // It represents all the data necessary for a node to authenticate over mTLS to
@@ -142,21 +146,9 @@
 	return curator.NodeID(nc.PublicKey())
 }
 
-// PublicGRPCServerCredentials returns gRPC TransportCredentials that should be
-// used by this node to run public gRPC services (ie. the AAA service and any
-// other management/user services).
-//
-// SECURITY: The returned TransportCredentials accepts _any_ client certificate
-// served by the client and does not perform any verification. The gRPC service
-// instance (via per-method checks or middleware) should perform user
-// authentication/authorization.
-func (nc *NodeCredentials) PublicGRPCServerCredentials() credentials.TransportCredentials {
-	tlsCert := tls.Certificate{
+func (nc *NodeCredentials) TLSCredentials() tls.Certificate {
+	return tls.Certificate{
 		Certificate: [][]byte{nc.node.Raw},
 		PrivateKey:  nc.private,
 	}
-	return credentials.NewTLS(&tls.Config{
-		Certificates: []tls.Certificate{tlsCert},
-		ClientAuth:   tls.RequireAnyClientCert,
-	})
 }
diff --git a/metropolis/node/core/curator/BUILD.bazel b/metropolis/node/core/curator/BUILD.bazel
index 4e98184..db02a3c 100644
--- a/metropolis/node/core/curator/BUILD.bazel
+++ b/metropolis/node/core/curator/BUILD.bazel
@@ -3,6 +3,7 @@
 go_library(
     name = "go_default_library",
     srcs = [
+        "authorization.go",
         "bootstrap.go",
         "curator.go",
         "impl_follower.go",
@@ -30,6 +31,7 @@
         "//metropolis/pkg/supervisor:go_default_library",
         "//metropolis/proto/api:go_default_library",
         "//metropolis/proto/common:go_default_library",
+        "//metropolis/proto/ext:go_default_library",
         "@io_etcd_go_etcd//clientv3:go_default_library",
         "@io_etcd_go_etcd//clientv3/concurrency:go_default_library",
         "@org_golang_google_grpc//:go_default_library",
@@ -38,6 +40,8 @@
         "@org_golang_google_grpc//peer:go_default_library",
         "@org_golang_google_grpc//status:go_default_library",
         "@org_golang_google_protobuf//proto:go_default_library",
+        "@org_golang_google_protobuf//reflect/protoreflect:go_default_library",
+        "@org_golang_google_protobuf//reflect/protoregistry:go_default_library",
         "@org_golang_x_sys//unix:go_default_library",
     ],
 )
@@ -55,11 +59,15 @@
         "//metropolis/node/core/localstorage:go_default_library",
         "//metropolis/node/core/localstorage/declarative:go_default_library",
         "//metropolis/pkg/event/memory:go_default_library",
+        "//metropolis/pkg/pki:go_default_library",
         "//metropolis/pkg/supervisor:go_default_library",
         "//metropolis/proto/api:go_default_library",
         "@io_etcd_go_etcd//clientv3:go_default_library",
         "@io_etcd_go_etcd//integration:go_default_library",
+        "@org_golang_google_grpc//:go_default_library",
         "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//credentials:go_default_library",
         "@org_golang_google_grpc//status:go_default_library",
+        "@org_golang_google_grpc//test/bufconn:go_default_library",
     ],
 )
diff --git a/metropolis/node/core/curator/authorization.go b/metropolis/node/core/curator/authorization.go
new file mode 100644
index 0000000..066c46b
--- /dev/null
+++ b/metropolis/node/core/curator/authorization.go
@@ -0,0 +1,148 @@
+package curator
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"strings"
+
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/peer"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/reflect/protoreflect"
+	"google.golang.org/protobuf/reflect/protoregistry"
+
+	apb "source.monogon.dev/metropolis/proto/api"
+	epb "source.monogon.dev/metropolis/proto/ext"
+)
+
+// listenerSecurity are the security options for the listener, relating to
+// authentication and authorization.
+//
+// They are factored out to a separate struct for ease of testing.
+type listenerSecurity struct {
+	// nodeCredentials is the TLS certificate/key of the node that the listener
+	// is running on. It should be signed by clusterCACertificate.
+	nodeCredentials tls.Certificate
+	// clusterCACertificate is the cluster's CA certificate. It will be used to
+	// authenticate the client certificates of incoming gRPC connections.
+	clusterCACertificate *x509.Certificate
+}
+
+// setupPublicGRPC returns a grpc.Server ready to listen and serve all public
+// gRPC APIs that the listener should run, with all calls being authenticated
+// and authorized based on the data in listenerSecurity. The argument 'impls' is
+// the object implementing the gRPC APIs.
+func (l *listenerSecurity) setupPublicGRPC(impls services) *grpc.Server {
+	publicCreds := credentials.NewTLS(&tls.Config{
+		Certificates: []tls.Certificate{l.nodeCredentials},
+		ClientAuth:   tls.RequestClientCert,
+	})
+
+	s := grpc.NewServer(
+		grpc.Creds(publicCreds),
+		grpc.UnaryInterceptor(l.unaryInterceptor),
+		grpc.StreamInterceptor(l.streamInterceptor),
+	)
+	apb.RegisterAAAServer(s, impls)
+	apb.RegisterManagementServer(s, impls)
+	return s
+}
+
+// authorize performs an authorization check for the given gRPC context
+// (containing peer information) and given RPC method name (as obtained from
+// FullMethodName in {Unary,Stream}ServerInfo). The actual authorization
+// requirements per method are retrieved from the Authorization protobuf
+// option applied to the RPC method.
+//
+// If the peer (as retrieved from the context) is authorized to run this method,
+// no error is returned. Otherwise, a gRPC status is returned outlining the
+// reason the authorization being rejected.
+func (l *listenerSecurity) authorize(ctx context.Context, methodName string) error {
+	if !strings.HasPrefix(methodName, "/") {
+		return status.Errorf(codes.InvalidArgument, "invalid method name %q", methodName)
+	}
+	methodName = strings.ReplaceAll(methodName[1:], "/", ".")
+	desc, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(methodName))
+	if err != nil {
+		return status.Errorf(codes.InvalidArgument, "could not retrieve descriptor for method: %v", err)
+	}
+	method, ok := desc.(protoreflect.MethodDescriptor)
+	if !ok {
+		return status.Error(codes.InvalidArgument, "querying method name did not yield a MethodDescriptor")
+	}
+
+	// Get authorization extension, defaults to no options set.
+	authz, ok := proto.GetExtension(method.Options(), epb.E_Authorization).(*epb.Authorization)
+	if !ok || authz == nil {
+		authz = &epb.Authorization{}
+	}
+
+	// If unauthenticated connections are allowed, let them through immediately.
+	if authz.AllowUnauthenticated && len(authz.Need) == 0 {
+		return nil
+	}
+
+	// Otherwise, we check that the other side of the connection is authenticated
+	// using a valid cluster CA client certificate.
+	p, ok := peer.FromContext(ctx)
+	if !ok {
+		return status.Error(codes.Unavailable, "could not retrive peer info")
+	}
+	tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
+	if !ok {
+		return status.Error(codes.Unauthenticated, "connection not secure")
+	}
+	count := len(tlsInfo.State.PeerCertificates)
+	if count == 0 {
+		return status.Errorf(codes.Unauthenticated, "no client certificate presented")
+	}
+	if count > 1 {
+		return status.Errorf(codes.Unauthenticated, "exactly one client certificate must be sent (got %d)", count)
+	}
+	pCert := tlsInfo.State.PeerCertificates[0]
+
+	// Ensure that the certificate is signed by the cluster CA.
+	if err := pCert.CheckSignatureFrom(l.clusterCACertificate); err != nil {
+		return status.Errorf(codes.Unauthenticated, "invalid client certificate: %v", err)
+	}
+	// Ensure that the certificate is a client certificate.
+	// TODO(q3k): synchronize this with //metropolis/pkg/pki Client()/Server()/...
+	isClient := false
+	for _, ku := range pCert.ExtKeyUsage {
+		if ku == x509.ExtKeyUsageClientAuth {
+			isClient = true
+			break
+		}
+	}
+	if !isClient {
+		return status.Error(codes.PermissionDenied, "presented certificate is not a client certificate")
+	}
+
+	// MVP: all permissions are granted to all users.
+	// TODO(q3k): check authz.Need once we have a user/identity system implemented.
+	return nil
+}
+
+// streamInterceptor is a gRPC server stream interceptor that performs
+// authentication and authorization of incoming RPCs based on the Authorization
+// option set on each method.
+func (l *listenerSecurity) streamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+	if err := l.authorize(ss.Context(), info.FullMethod); err != nil {
+		return err
+	}
+	return handler(srv, ss)
+}
+
+// unaryInterceptor is a gRPC server unary interceptor that performs
+// authentication and authorization of incoming RPCs based on the Authorization
+// option set on each method.
+func (l *listenerSecurity) unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
+	if err := l.authorize(ctx, info.FullMethod); err != nil {
+		return nil, err
+	}
+	return handler(ctx, req)
+}
diff --git a/metropolis/node/core/curator/curator.go b/metropolis/node/core/curator/curator.go
index c0e224f..a8504f9 100644
--- a/metropolis/node/core/curator/curator.go
+++ b/metropolis/node/core/curator/curator.go
@@ -13,13 +13,14 @@
 
 import (
 	"context"
+	"crypto/tls"
+	"crypto/x509"
 	"errors"
 	"fmt"
 	"time"
 
 	"go.etcd.io/etcd/clientv3/concurrency"
 	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials"
 	"google.golang.org/protobuf/proto"
 
 	"source.monogon.dev/metropolis/node/core/consensus/client"
@@ -47,8 +48,16 @@
 	LeaderTTL time.Duration
 	// Directory is the curator ephemeral directory in which the curator will
 	// store its local domain socket for connections from the node.
-	Directory         *localstorage.EphemeralCuratorDirectory
-	ServerCredentials credentials.TransportCredentials
+	Directory *localstorage.EphemeralCuratorDirectory
+
+	// ServerCredentials is the TLS certificate/key of the node that the curator
+	// will use to run public gRPC services. It should be signed by
+	// ClusterCACertificate.
+	ServerCredentials tls.Certificate
+	// ClusterCACertificate is the cluster's CA certificate. It will be used to
+	// authenticate the client certificates of incoming connections to the public
+	// gRPC services.
+	ClusterCACertificate *x509.Certificate
 }
 
 // Service is the Curator service. See the package-level documentation for more
@@ -260,8 +269,11 @@
 	// providing the Curator API to consumers, dispatching to either a locally
 	// running leader, or forwarding to a remotely running leader.
 	lis := listener{
-		directory:     s.config.Directory,
-		publicCreds:   s.config.ServerCredentials,
+		directory: s.config.Directory,
+		listenerSecurity: listenerSecurity{
+			nodeCredentials:      s.config.ServerCredentials,
+			clusterCACertificate: s.config.ClusterCACertificate,
+		},
 		electionWatch: s.electionWatch,
 		etcd:          s.config.Etcd,
 		dispatchC:     make(chan dispatchRequest),
diff --git a/metropolis/node/core/curator/impl_leader_management.go b/metropolis/node/core/curator/impl_leader_management.go
index 4e519b3..1895888 100644
--- a/metropolis/node/core/curator/impl_leader_management.go
+++ b/metropolis/node/core/curator/impl_leader_management.go
@@ -38,8 +38,6 @@
 )
 
 func (l *leaderManagement) GetRegisterTicket(ctx context.Context, req *apb.GetRegisterTicketRequest) (*apb.GetRegisterTicketResponse, error) {
-	// TODO9(q3k): authenticate and authorize
-
 	// Retrieve existing ticket, if any.
 	res, err := l.txnAsLeader(ctx, clientv3.OpGet(registerTicketEtcdPath))
 	if err != nil {
diff --git a/metropolis/node/core/curator/impl_leader_test.go b/metropolis/node/core/curator/impl_leader_test.go
index 0145e0c..2aa8e53 100644
--- a/metropolis/node/core/curator/impl_leader_test.go
+++ b/metropolis/node/core/curator/impl_leader_test.go
@@ -3,20 +3,33 @@
 import (
 	"bytes"
 	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"net"
 	"testing"
 
 	"go.etcd.io/etcd/integration"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/test/bufconn"
 
 	"source.monogon.dev/metropolis/node/core/consensus/client"
+	"source.monogon.dev/metropolis/pkg/pki"
 	apb "source.monogon.dev/metropolis/proto/api"
 )
 
 // fakeLeader creates a curatorLeader without any underlying leader election, in
-// its own etcd namespace.
+// its own etcd namespace. It starts a gRPC listener of its public services
+// implementation and returns a client to it.
+//
+// The entire gRPC layer is encrypted, authenticated and authorized in the same
+// way as by the full Curator codebase running in Metropolis. An ephemeral
+// cluster CA and node/manager credentials are created, and are used to
+// establish a secure channel when creating the gRPC listener and client.
 //
 // This is used to test functionality of the individual curatorLeader RPC
 // implementations without the overhead of having to wait for a leader election.
-func fakeLeader(t *testing.T) (*curatorLeader, context.CancelFunc) {
+func fakeLeader(t *testing.T) (grpc.ClientConnInterface, context.CancelFunc) {
 	t.Helper()
 	// Set up context whose cancel function will be returned to the user for
 	// terminating all harnesses started by this function.
@@ -45,29 +58,77 @@
 	}
 	lockRev := res.Header.Revision
 
-	// Return a normal curator leader object that directly implements the tested
-	// RPC methods. This will be exercised by tests.
-	return newCuratorLeader(leadership{
+	// Build a curator leader object. This implements methods that will be
+	// exercised by tests.
+	leader := newCuratorLeader(leadership{
 		lockKey: lockKey,
 		lockRev: lockRev,
 		etcd:    cl,
-	}), ctxC
+	})
+
+	// Build a test cluster PKI and node/manager certificates, and create the
+	// listener security parameters which will authenticate incoming requests.
+	node, manager, ca := pki.EphemeralClusterCredentials(t)
+	sec := &listenerSecurity{
+		nodeCredentials:      node,
+		clusterCACertificate: ca,
+	}
+
+	// Create a curator gRPC server which performs authentication as per the created
+	// listenerSecurity and is backed by the created leader.
+	srv := sec.setupPublicGRPC(leader)
+	// The gRPC server will listen on an internal 'loopback' buffer.
+	lis := bufconn.Listen(1024 * 1024)
+	go func() {
+		if err := srv.Serve(lis); err != nil {
+			t.Fatalf("GRPC serve failed: %v", err)
+		}
+	}()
+	// Stop the gRPC server on context cancel.
+	go func() {
+		<-ctx.Done()
+		srv.Stop()
+	}()
+
+	// Create an authenticated manager gRPC client.
+	// TODO(q3k): factor this out to its own library, alongside the code in //metropolis/test/e2e/client.go.
+	pool := x509.NewCertPool()
+	pool.AddCert(ca)
+	gclCreds := credentials.NewTLS(&tls.Config{
+		Certificates: []tls.Certificate{manager},
+		RootCAs:      pool,
+	})
+	gcl, err := grpc.Dial("test-server", grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) {
+		return lis.Dial()
+	}), grpc.WithTransportCredentials(gclCreds))
+	if err != nil {
+		t.Fatalf("Dialing local GRPC failed: %v", err)
+	}
+	// Close the client on context cancel.
+	go func() {
+		<-ctx.Done()
+		gcl.Close()
+	}()
+
+	return gcl, ctxC
 }
 
 // TestManagementRegisterTicket exercises the Management.GetRegisterTicket RPC.
 func TestManagementRegisterTicket(t *testing.T) {
-	l, cancel := fakeLeader(t)
+	cl, cancel := fakeLeader(t)
 	defer cancel()
 
+	mgmt := apb.NewManagementClient(cl)
+
 	ctx, ctxC := context.WithCancel(context.Background())
 	defer ctxC()
 
 	// Retrieve ticket twice.
-	res1, err := l.GetRegisterTicket(ctx, &apb.GetRegisterTicketRequest{})
+	res1, err := mgmt.GetRegisterTicket(ctx, &apb.GetRegisterTicketRequest{})
 	if err != nil {
 		t.Fatalf("GetRegisterTicket failed: %v", err)
 	}
-	res2, err := l.GetRegisterTicket(ctx, &apb.GetRegisterTicketRequest{})
+	res2, err := mgmt.GetRegisterTicket(ctx, &apb.GetRegisterTicketRequest{})
 	if err != nil {
 		t.Fatalf("GetRegisterTicket failed: %v", err)
 	}
diff --git a/metropolis/node/core/curator/listener.go b/metropolis/node/core/curator/listener.go
index b4f2b4d..5a8cb5e 100644
--- a/metropolis/node/core/curator/listener.go
+++ b/metropolis/node/core/curator/listener.go
@@ -8,7 +8,6 @@
 
 	"google.golang.org/grpc"
 	"google.golang.org/grpc/codes"
-	"google.golang.org/grpc/credentials"
 	"google.golang.org/grpc/status"
 
 	"source.monogon.dev/metropolis/node"
@@ -38,13 +37,14 @@
 // some calls might not be idempotent and the caller is better equipped to know
 // when to retry.
 type listener struct {
+	listenerSecurity
+
 	// etcd is a client to the locally running consensus (etcd) server which is used
 	// both for storing lock/leader election status and actual Curator data.
 	etcd client.Namespaced
 	// directory is the ephemeral directory in which the local gRPC socket will
 	// be available for node-local consumers.
-	directory   *localstorage.EphemeralCuratorDirectory
-	publicCreds credentials.TransportCredentials
+	directory *localstorage.EphemeralCuratorDirectory
 	// electionWatch is a function that returns an active electionWatcher for the
 	// listener to use when determining local leadership. As the listener may
 	// restart on error, this factory-function is used instead of an electionWatcher
@@ -205,11 +205,9 @@
 	}
 
 	srvLocal := grpc.NewServer()
-	srvPublic := grpc.NewServer(grpc.Creds(l.publicCreds))
-
 	cpb.RegisterCuratorServer(srvLocal, l)
-	apb.RegisterAAAServer(srvPublic, l)
-	apb.RegisterManagementServer(srvPublic, l)
+
+	srvPublic := l.setupPublicGRPC(l)
 
 	err := supervisor.Run(ctx, "local", func(ctx context.Context) error {
 		lisLocal, err := net.ListenUnix("unix", &net.UnixAddr{Name: l.directory.ClientSocket.FullPath(), Net: "unix"})
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 71f4227..d378934 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -171,7 +171,6 @@
 		}
 
 		// TODO(q3k): restart curator on credentials change?
-		curatorServerCreds := status.Credentials.PublicGRPCServerCredentials()
 
 		// Start cluster curator. The cluster curator is responsible for lifecycle
 		// management of the cluster.
@@ -180,9 +179,10 @@
 			Etcd:   ckv,
 			NodeID: status.Credentials.ID(),
 			// TODO(q3k): make this configurable?
-			LeaderTTL:         time.Second * 5,
-			Directory:         &root.Ephemeral.Curator,
-			ServerCredentials: curatorServerCreds,
+			LeaderTTL:            time.Second * 5,
+			Directory:            &root.Ephemeral.Curator,
+			ServerCredentials:    status.Credentials.TLSCredentials(),
+			ClusterCACertificate: status.Credentials.ClusterCA(),
 		})
 		if err := supervisor.Run(ctx, "curator", c.Run); err != nil {
 			close(trapdoor)
diff --git a/metropolis/pkg/pki/BUILD.bazel b/metropolis/pkg/pki/BUILD.bazel
index c215ce2..547d733 100644
--- a/metropolis/pkg/pki/BUILD.bazel
+++ b/metropolis/pkg/pki/BUILD.bazel
@@ -5,6 +5,7 @@
     srcs = [
         "ca.go",
         "certificate.go",
+        "testhelpers.go",
         "x509.go",
     ],
     importpath = "source.monogon.dev/metropolis/pkg/pki",
diff --git a/metropolis/pkg/pki/testhelpers.go b/metropolis/pkg/pki/testhelpers.go
new file mode 100644
index 0000000..f975967
--- /dev/null
+++ b/metropolis/pkg/pki/testhelpers.go
@@ -0,0 +1,65 @@
+package pki
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"testing"
+)
+
+// EphemeralClusterCredentials returns a pair of node and manager
+// tls.Certificates signed by a CA certificate.
+//
+// All of these are ephemeral, ie. not stored anywhere - including the CA
+// certificate. This function is for use by tests which want to bring up a
+// minimum set of PKI credentials for a fake Metropolis cluster.
+func EphemeralClusterCredentials(t *testing.T) (node, manager tls.Certificate, ca *x509.Certificate) {
+	ctx := context.Background()
+
+	ns := Namespaced("unused")
+	caCert := Certificate{
+		Namespace: &ns,
+		Issuer:    SelfSigned,
+		Template:  CA("test cluster ca"),
+		Mode:      CertificateEphemeral,
+	}
+	caBytes, err := caCert.Ensure(ctx, nil)
+	if err != nil {
+		t.Fatalf("Could not ensure CA certificate: %v", err)
+	}
+	ca, err = x509.ParseCertificate(caBytes)
+	if err != nil {
+		t.Fatalf("Could not parse new CA certificate: %v", err)
+	}
+
+	nodeCert := Certificate{
+		Namespace: &ns,
+		Issuer:    &caCert,
+		Template:  Server([]string{"test-server"}, nil),
+		Mode:      CertificateEphemeral,
+	}
+	nodeBytes, err := nodeCert.Ensure(ctx, nil)
+	if err != nil {
+		t.Fatalf("Could not ensure node certificate: %v", err)
+	}
+	node = tls.Certificate{
+		Certificate: [][]byte{nodeBytes},
+		PrivateKey:  nodeCert.PrivateKey,
+	}
+
+	managerCert := Certificate{
+		Namespace: &ns,
+		Issuer:    &caCert,
+		Template:  Client("owner", nil),
+		Mode:      CertificateEphemeral,
+	}
+	managerBytes, err := managerCert.Ensure(ctx, nil)
+	if err != nil {
+		t.Fatalf("Could not ensure manager certificate: %v", err)
+	}
+	manager = tls.Certificate{
+		Certificate: [][]byte{managerBytes},
+		PrivateKey:  managerCert.PrivateKey,
+	}
+	return
+}
diff --git a/metropolis/proto/api/BUILD.bazel b/metropolis/proto/api/BUILD.bazel
index 61d14fb..ef8885d 100644
--- a/metropolis/proto/api/BUILD.bazel
+++ b/metropolis/proto/api/BUILD.bazel
@@ -11,6 +11,7 @@
         "management.proto",
     ],
     visibility = ["//visibility:public"],
+    deps = ["//metropolis/proto/ext:ext_proto"],
 )
 
 go_proto_library(
@@ -19,6 +20,7 @@
     importpath = "source.monogon.dev/metropolis/proto/api",
     proto = ":api_proto",
     visibility = ["//visibility:public"],
+    deps = ["//metropolis/proto/ext:go_default_library"],
 )
 
 go_library(
diff --git a/metropolis/proto/api/aaa.proto b/metropolis/proto/api/aaa.proto
index e469d0d..faf6dda 100644
--- a/metropolis/proto/api/aaa.proto
+++ b/metropolis/proto/api/aaa.proto
@@ -18,6 +18,8 @@
 package metropolis.proto.api;
 option go_package = "source.monogon.dev/metropolis/proto/api";
 
+import "metropolis/proto/ext/authorization.proto";
+
 // Authentication, authorization and accounting.
 service AAA {
     // Escrow is an endpoint used to retrieve short-lived access credentials to
@@ -144,7 +146,13 @@
     // will lead to retrieving identities from with the same namespace of
     // entities.
     //
-    rpc Escrow(stream EscrowFromClient) returns (stream EscrowFromServer);
+    rpc Escrow(stream EscrowFromClient) returns (stream EscrowFromServer) {
+        option (metropolis.proto.ext.authorization) = {
+            // The AAA implementation performs its own checks as needed, so the
+            // RPC middleware should allow everything through.
+            allow_unauthenticated: true
+        };
+    }
 }
 
 message EscrowFromClient {
diff --git a/metropolis/proto/api/management.proto b/metropolis/proto/api/management.proto
index c0b8332..ae7dd8d 100644
--- a/metropolis/proto/api/management.proto
+++ b/metropolis/proto/api/management.proto
@@ -2,6 +2,8 @@
 package metropolis.proto.api;
 option go_package = "source.monogon.dev/metropolis/proto/api";
 
+import "metropolis/proto/ext/authorization.proto";
+
 // Management service available to Cluster Managers.
 service Management {
     // GetRegisterTicket retrieves the current RegisterTicket which is required
@@ -10,7 +12,11 @@
     // registration. Instead, it is used to guard the API surface of the
     // Register RPC from potential denial of service attacks, and can be
     // regenerated at any time in case it leaks.
-    rpc GetRegisterTicket(GetRegisterTicketRequest) returns (GetRegisterTicketResponse);
+    rpc GetRegisterTicket(GetRegisterTicketRequest) returns (GetRegisterTicketResponse) {
+        option (metropolis.proto.ext.authorization) = {
+            need: PERMISSION_GET_REGISTER_TICKET
+        };
+    }
 }
 
 message GetRegisterTicketRequest {
diff --git a/metropolis/proto/ext/BUILD.bazel b/metropolis/proto/ext/BUILD.bazel
new file mode 100644
index 0000000..c93882e
--- /dev/null
+++ b/metropolis/proto/ext/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "ext_proto",
+    srcs = ["authorization.proto"],
+    visibility = ["//visibility:public"],
+    deps = ["@com_google_protobuf//:descriptor_proto"],
+)
+
+go_proto_library(
+    name = "ext_go_proto",
+    importpath = "source.monogon.dev/metropolis/proto/ext",
+    proto = ":ext_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "go_default_library",
+    embed = [":ext_go_proto"],
+    importpath = "source.monogon.dev/metropolis/proto/ext",
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/proto/ext/authorization.proto b/metropolis/proto/ext/authorization.proto
new file mode 100644
index 0000000..cc9082f
--- /dev/null
+++ b/metropolis/proto/ext/authorization.proto
@@ -0,0 +1,36 @@
+syntax = "proto3";
+package metropolis.proto.ext;
+option go_package = "source.monogon.dev/metropolis/proto/ext";
+
+import "google/protobuf/descriptor.proto";
+
+extend google.protobuf.MethodOptions {
+    // Set authorization policy for this RPC. If not set but the service is
+    // configured to use authorization, the default/zero value of the
+    // Authorization message will be used (effectively allowing all
+    // authenticated users).
+    Authorization authorization = 1000;
+}
+
+
+// Permission is a combined activity/object that an identity can perform in the
+// cluster.
+//
+// MVP: this might get replaced with a full activity/object split later on.
+enum Permission {
+    PERMISSION_UNSPECIFIED = 0;
+    PERMISSION_GET_REGISTER_TICKET = 1;
+}
+
+// Authorization policy for an RPC method. This message/API does not have the
+// same stability guarantees as the rest of Metropolis APIs - it is internal,
+// might change in wire and text incompatible ways and should not be used by
+// consumers of the API.
+message Authorization {
+    // Set of permissions required from the caller.
+    repeated Permission need = 1;
+    // If set, this API can be called unauthorized and unauthenticated, thereby
+    // allowing full access to anyone, including public access by anyone with
+    // network connectivity to the cluster.. Ignored if `need` is non-empty.
+    bool allow_unauthenticated = 2;
+}