go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/service/service.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package service
    16  
    17  import (
    18  	"bytes"
    19  	"compress/zlib"
    20  	"context"
    21  	"crypto/sha256"
    22  	"encoding/base64"
    23  	"encoding/hex"
    24  	"encoding/json"
    25  	"fmt"
    26  	"io"
    27  	"time"
    28  
    29  	"google.golang.org/protobuf/proto"
    30  
    31  	"go.chromium.org/luci/common/logging"
    32  
    33  	"go.chromium.org/luci/server/auth/authdb"
    34  	"go.chromium.org/luci/server/auth/internal"
    35  	"go.chromium.org/luci/server/auth/service/protocol"
    36  	"go.chromium.org/luci/server/auth/signing"
    37  )
    38  
    39  // oauthScopes is OAuth scopes required for using AuthService API (including
    40  // PubSub part).
    41  var oauthScopes = []string{
    42  	"https://www.googleapis.com/auth/userinfo.email",
    43  	"https://www.googleapis.com/auth/pubsub",
    44  }
    45  
    46  // Notification represents a notification about AuthDB change. Must be acked
    47  // once processed.
    48  type Notification struct {
    49  	Revision int64 // new auth DB revision
    50  
    51  	service      *AuthService // parent service
    52  	subscription string       // subscription name the change was pulled from
    53  	ackIDs       []string     // IDs of all messages to ack
    54  }
    55  
    56  // Acknowledge tells PubSub to stop redelivering this notification.
    57  func (n *Notification) Acknowledge(ctx context.Context) error {
    58  	if n.service != nil { // may be nil if Notification is generated in tests
    59  		return n.service.ackMessages(ctx, n.subscription, n.ackIDs)
    60  	}
    61  	return nil
    62  }
    63  
    64  // pubSubMessage is received via PubSub when AuthDB changes.
    65  type pubSubMessage struct {
    66  	AckID   string `json:"ackId"` // set only when pulling
    67  	Message struct {
    68  		MessageID  string            `json:"messageId"`
    69  		Data       string            `json:"data"`
    70  		Attributes map[string]string `json:"attributes"`
    71  	} `json:"message"`
    72  }
    73  
    74  // Snapshot contains AuthDB proto message (all user groups and other information
    75  // received from auth_service), along with its revision number, timestamp of
    76  // when it was created, and URL of a service it was fetched from.
    77  type Snapshot struct {
    78  	AuthDB         *protocol.AuthDB
    79  	AuthServiceURL string
    80  	Rev            int64
    81  	Created        time.Time
    82  }
    83  
    84  // AuthDBAccess describes how an authorized reader can access the AuthDB.
    85  //
    86  // See RequestAccess.
    87  type AuthDBAccess struct {
    88  	NotificationTopic string // pubsub topic name "project/<project>/topics/<topic>"
    89  	StorageDumpPath   string // GCS storage path "<bucket>/<object>", may be empty
    90  }
    91  
    92  // AuthService represents API exposed by auth_service.
    93  //
    94  // It is a fairy low-level API, you must have good reasons for using it.
    95  type AuthService struct {
    96  	// URL is root URL (with protocol) of auth_service (e.g. "https://<host>").
    97  	URL string
    98  	// OAuthScopes is scopes to use for authentication (or nil for defaults).
    99  	OAuthScopes []string
   100  
   101  	// pubSubURLRoot is root URL of PubSub service. Mocked in tests.
   102  	pubSubURLRoot string
   103  }
   104  
   105  // pubSubURL returns full URL to pubsub API endpoint.
   106  func (s *AuthService) pubSubURL(path string) string {
   107  	if s.pubSubURLRoot != "" {
   108  		return s.pubSubURLRoot + path
   109  	}
   110  	return "https://pubsub.googleapis.com/v1/" + path
   111  }
   112  
   113  // oauthScopes returns a list of OAuth scopes to use for authentication.
   114  func (s *AuthService) oauthScopes() []string {
   115  	if s.OAuthScopes != nil {
   116  		return s.OAuthScopes
   117  	}
   118  	return oauthScopes
   119  }
   120  
   121  // RequestAccess asks Auth Service to grant the caller (us) access to the AuthDB
   122  // change notifications PubSub topic and AuthDB GCS dump.
   123  //
   124  // This works only if the caller is in "auth-trusted-services" group. As soon
   125  // as the caller is removed from this group, the access is revoked.
   126  func (s *AuthService) RequestAccess(ctx context.Context) (*AuthDBAccess, error) {
   127  	// See appengine/auth_service/handlers_frontend.py.
   128  	var response struct {
   129  		Topic string `json:"topic"`
   130  		GS    struct {
   131  			AuthDBGSPath string `json:"auth_db_gs_path"`
   132  		} `json:"gs"`
   133  	}
   134  	req := internal.Request{
   135  		Method: "POST",
   136  		URL:    s.URL + "/auth_service/api/v1/authdb/subscription/authorization",
   137  		Scopes: s.oauthScopes(),
   138  		Body:   map[string]string{},
   139  		Out:    &response,
   140  	}
   141  	if err := req.Do(ctx); err != nil {
   142  		return nil, err
   143  	}
   144  	return &AuthDBAccess{
   145  		NotificationTopic: response.Topic,
   146  		StorageDumpPath:   response.GS.AuthDBGSPath, // may be ""
   147  	}, nil
   148  }
   149  
   150  // EnsureSubscription creates a new subscription to AuthDB change notifications
   151  // topic or changes its pushURL if it already exists. `subscription` is full
   152  // subscription name e.g. "projects/<projectid>/subscriptions/<subid>". Name of
   153  // the topic is fetched from the auth service. Returns nil if such subscription
   154  // already exists.
   155  func (s *AuthService) EnsureSubscription(ctx context.Context, subscription, pushURL string) error {
   156  	// Subscription already exists?
   157  	var existing struct {
   158  		Error struct {
   159  			Code int `json:"code"`
   160  		} `json:"error"`
   161  		PushConfig struct {
   162  			PushEndpoint string `json:"pushEndpoint"`
   163  		} `json:"pushConfig"`
   164  	}
   165  	req := internal.Request{
   166  		Method: "GET",
   167  		URL:    s.pubSubURL(subscription),
   168  		Scopes: s.oauthScopes(),
   169  		Out:    &existing,
   170  	}
   171  	err := req.Do(ctx)
   172  	if err != nil && existing.Error.Code != 404 {
   173  		return err
   174  	}
   175  
   176  	// Create a new subscription if existing is missing.
   177  	if err != nil {
   178  		// Make sure caller has access to the PubSub topic.
   179  		access, err := s.RequestAccess(ctx)
   180  		if err != nil {
   181  			return err
   182  		}
   183  		// Create the subscription.
   184  		var config struct {
   185  			Topic      string `json:"topic"`
   186  			PushConfig struct {
   187  				PushEndpoint string `json:"pushEndpoint"`
   188  			} `json:"pushConfig"`
   189  			AckDeadlineSeconds int `json:"ackDeadlineSeconds"`
   190  		}
   191  		config.Topic = access.NotificationTopic
   192  		config.PushConfig.PushEndpoint = pushURL
   193  		config.AckDeadlineSeconds = 60
   194  		req = internal.Request{
   195  			Method: "PUT",
   196  			URL:    s.pubSubURL(subscription),
   197  			Scopes: s.oauthScopes(),
   198  			Body:   &config,
   199  		}
   200  		return req.Do(ctx)
   201  	}
   202  
   203  	// Is existing subscription configured correctly already?
   204  	if existing.PushConfig.PushEndpoint == pushURL {
   205  		return nil
   206  	}
   207  
   208  	// Reconfigure existing subscription.
   209  	var request struct {
   210  		PushConfig struct {
   211  			PushEndpoint string `json:"pushEndpoint"`
   212  		} `json:"pushConfig"`
   213  	}
   214  	request.PushConfig.PushEndpoint = pushURL
   215  
   216  	req = internal.Request{
   217  		Method: "POST",
   218  		URL:    s.pubSubURL(subscription + ":modifyPushConfig"),
   219  		Scopes: s.oauthScopes(),
   220  		Body:   &request,
   221  	}
   222  	return req.Do(ctx)
   223  }
   224  
   225  // DeleteSubscription removes PubSub subscription if it exists.
   226  func (s *AuthService) DeleteSubscription(ctx context.Context, subscription string) error {
   227  	var reply struct {
   228  		Error struct {
   229  			Code int `json:"code"`
   230  		} `json:"error"`
   231  	}
   232  	req := internal.Request{
   233  		Method: "DELETE",
   234  		URL:    s.pubSubURL(subscription),
   235  		Scopes: s.oauthScopes(),
   236  		Out:    &reply,
   237  	}
   238  	if err := req.Do(ctx); err != nil && reply.Error.Code != 404 {
   239  		return err
   240  	}
   241  	return nil
   242  }
   243  
   244  // PullPubSub pulls pending PubSub messages (from subscription created
   245  // previously by EnsureSubscription), authenticates them, and converts
   246  // them into Notification object. Returns (nil, nil) if no pending messages.
   247  // Does not wait for messages to arrive.
   248  func (s *AuthService) PullPubSub(ctx context.Context, subscription string) (*Notification, error) {
   249  	request := struct {
   250  		ReturnImmediately bool `json:"returnImmediately"`
   251  		MaxMessages       int  `json:"maxMessages"`
   252  	}{true, 1000}
   253  
   254  	response := struct {
   255  		ReceivedMessages []pubSubMessage `json:"receivedMessages"`
   256  	}{}
   257  
   258  	req := internal.Request{
   259  		Method: "POST",
   260  		URL:    s.pubSubURL(subscription + ":pull"),
   261  		Scopes: s.oauthScopes(),
   262  		Body:   &request,
   263  		Out:    &response,
   264  	}
   265  	if err := req.Do(ctx); err != nil {
   266  		return nil, err
   267  	}
   268  	if len(response.ReceivedMessages) == 0 {
   269  		return nil, nil
   270  	}
   271  
   272  	logging.Infof(ctx, "Received PubSub notification: %d", len(response.ReceivedMessages))
   273  
   274  	// Grab certs to verify signatures on PubSub messages.
   275  	certs, err := signing.FetchCertificatesFromLUCIService(ctx, s.URL)
   276  	if err != nil {
   277  		return nil, err
   278  	}
   279  
   280  	// Find maximum AuthDB revision among all verified messages. Invalid messages
   281  	// are skipped (with logging).
   282  	maxRev := int64(-1)
   283  	ackIDs := ([]string)(nil)
   284  	for _, msg := range response.ReceivedMessages {
   285  		if msg.AckID != "" {
   286  			ackIDs = append(ackIDs, msg.AckID)
   287  		}
   288  		notify, err := s.decodeMessage(&msg, certs)
   289  		if err != nil {
   290  			logging.Warningf(ctx, "Bad signature on message %s - %s", msg.Message.MessageID, err)
   291  			continue
   292  		}
   293  		if notify.GetRevision().GetAuthDbRev() > maxRev {
   294  			maxRev = notify.GetRevision().GetAuthDbRev()
   295  		}
   296  	}
   297  
   298  	// All message are invalid and skipped? Try to acknowledge them to remove
   299  	// them from the queue, but don't error if it fails.
   300  	if maxRev == -1 {
   301  		err := s.ackMessages(ctx, subscription, ackIDs)
   302  		if err != nil {
   303  			logging.Warningf(ctx, "Failed to ack some PubSub messages (%v) - %s", ackIDs, err)
   304  		}
   305  		return nil, nil
   306  	}
   307  
   308  	logging.Infof(ctx, "Auth service notifies us that the most recent AuthDB revision is %d", maxRev)
   309  	return &Notification{
   310  		Revision:     maxRev,
   311  		service:      s,
   312  		subscription: subscription,
   313  		ackIDs:       ackIDs,
   314  	}, nil
   315  }
   316  
   317  // ProcessPubSubPush handles incoming PubSub push notification. `body` is
   318  // the entire body of the push HTTP request. Invalid messages are silently
   319  // skipped by returning nil error (to avoid redelivery). The error is still
   320  // logged though.
   321  func (s *AuthService) ProcessPubSubPush(ctx context.Context, body []byte) (*Notification, error) {
   322  	msg := pubSubMessage{}
   323  	if err := json.Unmarshal(body, &msg); err != nil {
   324  		logging.Errorf(ctx, "auth: bad PubSub notification, not JSON - %s", err)
   325  		return nil, nil
   326  	}
   327  
   328  	// It's fine to return error here. Certificate fetch can fail due to bad
   329  	// connectivity and we need a retry.
   330  	certs, err := signing.FetchCertificatesFromLUCIService(ctx, s.URL)
   331  	if err != nil {
   332  		return nil, err
   333  	}
   334  
   335  	notify, err := s.decodeMessage(&msg, certs)
   336  	if err != nil {
   337  		logging.Errorf(ctx, "auth: bad PubSub notification - %s", err)
   338  		return nil, nil
   339  	}
   340  
   341  	return &Notification{
   342  		Revision: notify.GetRevision().GetAuthDbRev(),
   343  		service:  s,
   344  	}, nil
   345  }
   346  
   347  ///
   348  
   349  // decodeMessage checks that the message payload was signed by Auth service key
   350  // and deserializes it.
   351  func (s *AuthService) decodeMessage(m *pubSubMessage, certs *signing.PublicCertificates) (*protocol.ChangeNotification, error) {
   352  	// Key name used to sign.
   353  	keyName := m.Message.Attributes["X-AuthDB-SigKey-v1"]
   354  	if keyName == "" {
   355  		return nil, fmt.Errorf("X-AuthDB-SigKey-v1 attribute is not set")
   356  	}
   357  
   358  	// The signature.
   359  	sigBase64 := m.Message.Attributes["X-AuthDB-SigVal-v1"]
   360  	if sigBase64 == "" {
   361  		return nil, fmt.Errorf("X-AuthDB-SigVal-v1 attribute is not set")
   362  	}
   363  	sig, err := base64.StdEncoding.DecodeString(sigBase64)
   364  	if err != nil {
   365  		return nil, fmt.Errorf("the message signature is not valid base64 - %s", err)
   366  	}
   367  
   368  	// Message body.
   369  	body, err := base64.StdEncoding.DecodeString(m.Message.Data)
   370  	if err != nil {
   371  		return nil, fmt.Errorf("the message body is not valid base64 - %s", err)
   372  	}
   373  
   374  	// Validate.
   375  	if err := certs.CheckSignature(keyName, body, sig); err != nil {
   376  		return nil, err
   377  	}
   378  
   379  	// Deserialize.
   380  	out := protocol.ChangeNotification{}
   381  	if err := proto.Unmarshal(body, &out); err != nil {
   382  		return nil, err
   383  	}
   384  	return &out, nil
   385  }
   386  
   387  // ackMessages acknowledges processing of pubsub messages.
   388  func (s *AuthService) ackMessages(ctx context.Context, subscription string, ackIDs []string) error {
   389  	if len(ackIDs) == 0 {
   390  		return nil
   391  	}
   392  	request := struct {
   393  		AckIDs []string `json:"ackIds"`
   394  	}{ackIDs}
   395  	req := internal.Request{
   396  		Method: "POST",
   397  		URL:    s.pubSubURL(subscription + ":acknowledge"),
   398  		Scopes: s.oauthScopes(),
   399  		Body:   &request,
   400  	}
   401  	return req.Do(ctx)
   402  }
   403  
   404  // GetLatestSnapshotRevision fetches revision number of the latest AuthDB
   405  // snapshot.
   406  func (s *AuthService) GetLatestSnapshotRevision(ctx context.Context) (int64, error) {
   407  	var out struct {
   408  		Snapshot struct {
   409  			Rev int64 `json:"auth_db_rev"`
   410  		} `json:"snapshot"`
   411  	}
   412  	req := internal.Request{
   413  		Method: "GET",
   414  		URL:    s.URL + "/auth_service/api/v1/authdb/revisions/latest?skip_body=1",
   415  		Scopes: s.oauthScopes(),
   416  		Out:    &out,
   417  	}
   418  	if err := req.Do(ctx); err != nil {
   419  		return 0, err
   420  	}
   421  	return out.Snapshot.Rev, nil
   422  }
   423  
   424  // GetSnapshot fetches AuthDB snapshot at given revision, unpacks and
   425  // validates it.
   426  func (s *AuthService) GetSnapshot(ctx context.Context, rev int64) (*Snapshot, error) {
   427  	// Fetch.
   428  	var out struct {
   429  		Snapshot struct {
   430  			Rev          int64  `json:"auth_db_rev"`
   431  			SHA256       string `json:"sha256"`
   432  			Created      int64  `json:"created_ts"`
   433  			DeflatedBody string `json:"deflated_body"`
   434  		} `json:"snapshot"`
   435  	}
   436  	req := internal.Request{
   437  		Method: "GET",
   438  		URL:    fmt.Sprintf("%s/auth_service/api/v1/authdb/revisions/%d", s.URL, rev),
   439  		Scopes: s.oauthScopes(),
   440  		Out:    &out,
   441  	}
   442  	if err := req.Do(ctx); err != nil {
   443  		return nil, err
   444  	}
   445  	if out.Snapshot.Rev != rev {
   446  		return nil, fmt.Errorf(
   447  			"auth: unexpected revision %d of AuthDB snapshot, expecting %d", out.Snapshot.Rev, rev)
   448  	}
   449  
   450  	// Decode base64.
   451  	deflated, err := base64.StdEncoding.DecodeString(out.Snapshot.DeflatedBody)
   452  	if err != nil {
   453  		return nil, err
   454  	}
   455  
   456  	// Inflate and calculate SHA256 of inflated blob.
   457  	r, err := zlib.NewReader(bytes.NewReader(deflated))
   458  	if err != nil {
   459  		return nil, err
   460  	}
   461  	defer r.Close()
   462  	blob := bytes.Buffer{}
   463  	hash := sha256.New()
   464  	if _, err := io.Copy(io.MultiWriter(&blob, hash), r); err != nil {
   465  		return nil, err
   466  	}
   467  
   468  	// Make sure SHA256 is correct.
   469  	digest := hex.EncodeToString(hash.Sum(nil))
   470  	if digest != out.Snapshot.SHA256 {
   471  		return nil, fmt.Errorf("auth: wrong SHA256 digest of AuthDB snapshot at rev %d", rev)
   472  	}
   473  
   474  	// Unmarshal.
   475  	msg := protocol.ReplicationPushRequest{}
   476  	if err := proto.Unmarshal(blob.Bytes(), &msg); err != nil {
   477  		return nil, err
   478  	}
   479  	revMsg := msg.GetRevision()
   480  	if revMsg == nil || revMsg.GetAuthDbRev() != rev {
   481  		return nil, fmt.Errorf("auth: bad 'revision' field in proto message (%v)", revMsg)
   482  	}
   483  
   484  	// Log some stats.
   485  	logging.Infof(
   486  		ctx, "auth: fetched AuthDB snapshot at rev %d (inflated size is %.1f Kb, deflated size is %.1f Kb)",
   487  		rev, float32(len(blob.Bytes()))/1024, float32(len(deflated))/1024)
   488  	logging.Infof(
   489  		ctx, "auth: AuthDB snapshot generated by %s (using components.auth v%s)",
   490  		revMsg.GetPrimaryId(), msg.GetAuthCodeVersion())
   491  
   492  	// Validate AuthDB by constructing SnapshotDB from it (with validation).
   493  	authDB := msg.GetAuthDb()
   494  	if authDB == nil {
   495  		return nil, fmt.Errorf("auth: 'auth_db' field is missing in proto message (%v)", &msg)
   496  	}
   497  	if _, err := authdb.NewSnapshotDB(authDB, s.URL, rev, true); err != nil {
   498  		return nil, err
   499  	}
   500  	return &Snapshot{
   501  		AuthDB:         authDB,
   502  		AuthServiceURL: s.URL,
   503  		Rev:            rev,
   504  		Created:        time.Unix(0, out.Snapshot.Created*1000),
   505  	}, nil
   506  }
   507  
   508  // DeflateAuthDB serializes AuthDB to byte buffer and compresses it with zlib.
   509  func DeflateAuthDB(msg *protocol.AuthDB) ([]byte, error) {
   510  	blob, err := proto.Marshal(msg)
   511  	if err != nil {
   512  		return nil, err
   513  	}
   514  	out := bytes.Buffer{}
   515  	writer := zlib.NewWriter(&out)
   516  	_, err1 := io.Copy(writer, bytes.NewReader(blob))
   517  	err2 := writer.Close()
   518  	if err1 != nil {
   519  		return nil, err1
   520  	}
   521  	if err2 != nil {
   522  		return nil, err2
   523  	}
   524  	return out.Bytes(), nil
   525  }
   526  
   527  // InflateAuthDB is reverse of DeflateAuthDB. It decompresses and deserializes
   528  // AuthDB message.
   529  func InflateAuthDB(blob []byte) (*protocol.AuthDB, error) {
   530  	reader, err := zlib.NewReader(bytes.NewReader(blob))
   531  	if err != nil {
   532  		return nil, err
   533  	}
   534  	defer reader.Close()
   535  	inflated := bytes.Buffer{}
   536  	if _, err := io.Copy(&inflated, reader); err != nil {
   537  		return nil, err
   538  	}
   539  	out := &protocol.AuthDB{}
   540  	if err := proto.Unmarshal(inflated.Bytes(), out); err != nil {
   541  		return nil, err
   542  	}
   543  	return out, nil
   544  }