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

     1  // Copyright 2016 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 authdb
    16  
    17  import (
    18  	"context"
    19  	"io"
    20  	"net"
    21  
    22  	"github.com/golang/protobuf/proto"
    23  	"go.opentelemetry.io/otel/attribute"
    24  
    25  	"go.chromium.org/luci/auth/identity"
    26  	"go.chromium.org/luci/common/errors"
    27  	"go.chromium.org/luci/common/logging"
    28  
    29  	"go.chromium.org/luci/server/auth/authdb/internal/certs"
    30  	"go.chromium.org/luci/server/auth/authdb/internal/graph"
    31  	"go.chromium.org/luci/server/auth/authdb/internal/ipaddr"
    32  	"go.chromium.org/luci/server/auth/authdb/internal/oauthid"
    33  	"go.chromium.org/luci/server/auth/authdb/internal/realmset"
    34  	"go.chromium.org/luci/server/auth/authdb/internal/seccfg"
    35  	"go.chromium.org/luci/server/auth/internal/tracing"
    36  	"go.chromium.org/luci/server/auth/realms"
    37  	"go.chromium.org/luci/server/auth/service/protocol"
    38  	"go.chromium.org/luci/server/auth/signing"
    39  )
    40  
    41  // SnapshotDB implements DB using AuthDB proto message.
    42  //
    43  // Use NewSnapshotDB to create new instances. Don't touch public fields
    44  // of existing instances.
    45  //
    46  // Zero value represents an empty AuthDB.
    47  type SnapshotDB struct {
    48  	AuthServiceURL string // where it was fetched from
    49  	Rev            int64  // its revision number
    50  
    51  	groups         *graph.QueryableGraph  // queryable representation of groups
    52  	realms         *realmset.Realms       // queryable representation of realms
    53  	clientIDs      oauthid.Allowlist      // set of allowed client IDs
    54  	allowlistedIPs ipaddr.Allowlist       // set of named IP allowlist
    55  	securityCfg    *seccfg.SecurityConfig // parsed SecurityConfig proto
    56  
    57  	tokenServiceURL   string       // URL of the token server as provided by Auth service
    58  	tokenServiceCerts certs.Bundle // cached public keys of the token server
    59  }
    60  
    61  var _ DB = &SnapshotDB{}
    62  
    63  // Revision returns a revision of an auth DB or 0 if it can't be determined.
    64  //
    65  // It's just a small helper that casts db to *SnapshotDB and extracts the
    66  // revision from there.
    67  func Revision(db DB) int64 {
    68  	if snap, _ := db.(*SnapshotDB); snap != nil {
    69  		return snap.Rev
    70  	}
    71  	return 0
    72  }
    73  
    74  // SnapshotDBFromTextProto constructs SnapshotDB by loading it from a text proto
    75  // with AuthDB message.
    76  func SnapshotDBFromTextProto(r io.Reader) (*SnapshotDB, error) {
    77  	blob, err := io.ReadAll(r)
    78  	if err != nil {
    79  		return nil, errors.Annotate(err, "failed to read the file").Err()
    80  	}
    81  	msg := &protocol.AuthDB{}
    82  	if err := proto.UnmarshalText(string(blob), msg); err != nil {
    83  		return nil, errors.Annotate(err, "not a valid AuthDB text proto file").Err()
    84  	}
    85  	db, err := NewSnapshotDB(msg, "", 0, true)
    86  	if err != nil {
    87  		return nil, errors.Annotate(err, "failed to validate AuthDB").Err()
    88  	}
    89  	return db, nil
    90  }
    91  
    92  // NewSnapshotDB creates new instance of SnapshotDB.
    93  //
    94  // It does some preprocessing to speed up subsequent checks. Returns errors if
    95  // it encounters inconsistencies.
    96  //
    97  // If 'validate' is false, skips some expensive validation steps, assuming they
    98  // were performed before, when AuthDB was initially received.
    99  func NewSnapshotDB(authDB *protocol.AuthDB, authServiceURL string, rev int64, validate bool) (*SnapshotDB, error) {
   100  	if validate {
   101  		if err := validateAuthDB(authDB); err != nil {
   102  			return nil, err
   103  		}
   104  	}
   105  
   106  	groups, err := graph.BuildQueryable(authDB.Groups)
   107  	if err != nil {
   108  		return nil, errors.Annotate(err, "failed to build groups graph").Err()
   109  	}
   110  
   111  	var realmSet *realmset.Realms
   112  	if authDB.Realms != nil {
   113  		realmSet, err = realmset.Build(authDB.Realms, groups, realms.RegisteredPermissions())
   114  		if err != nil {
   115  			return nil, errors.Annotate(err, "failed to prepare Realms DB").Err()
   116  		}
   117  	}
   118  
   119  	allowlistedIPs, err := ipaddr.NewAllowlist(authDB.IpWhitelists, authDB.IpWhitelistAssignments)
   120  	if err != nil {
   121  		return nil, errors.Annotate(err, "bad IP allowlist in AuthDB").Err()
   122  	}
   123  
   124  	securityCfg, err := seccfg.Parse(authDB.SecurityConfig)
   125  	if err != nil {
   126  		return nil, errors.Annotate(err, "bad SecurityConfig").Err()
   127  	}
   128  
   129  	return &SnapshotDB{
   130  		AuthServiceURL:    authServiceURL,
   131  		Rev:               rev,
   132  		groups:            groups,
   133  		realms:            realmSet,
   134  		clientIDs:         oauthid.NewAllowlist(authDB.OauthClientId, authDB.OauthAdditionalClientIds),
   135  		allowlistedIPs:    allowlistedIPs,
   136  		securityCfg:       securityCfg,
   137  		tokenServiceURL:   authDB.TokenServerUrl,
   138  		tokenServiceCerts: certs.Bundle{ServiceURL: authDB.TokenServerUrl},
   139  	}, nil
   140  }
   141  
   142  // IsAllowedOAuthClientID returns true if the given OAuth2 client ID can be used
   143  // to authorize access from the given email.
   144  func (db *SnapshotDB) IsAllowedOAuthClientID(_ context.Context, email, clientID string) (bool, error) {
   145  	return db.clientIDs.IsAllowedOAuthClientID(email, clientID), nil
   146  }
   147  
   148  // IsInternalService returns true if the given hostname belongs to a service
   149  // that is a part of the current LUCI deployment.
   150  //
   151  // What hosts are internal is controlled by 'internal_service_regexp' setting
   152  // in security.cfg in the Auth Service configs.
   153  func (db *SnapshotDB) IsInternalService(ctx context.Context, hostname string) (bool, error) {
   154  	if db.securityCfg != nil {
   155  		return db.securityCfg.IsInternalService(hostname), nil
   156  	}
   157  	return false, nil
   158  }
   159  
   160  // IsMember returns true if the given identity belongs to any of the groups.
   161  //
   162  // Unknown groups are considered empty, but are logged as warnings.
   163  // May return errors if underlying datastore has issues.
   164  func (db *SnapshotDB) IsMember(ctx context.Context, id identity.Identity, groups []string) (ok bool, err error) {
   165  	_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.IsMember",
   166  		attribute.StringSlice("cr.dev.groups", groups),
   167  	)
   168  	defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }()
   169  
   170  	if db.groups == nil {
   171  		return false, nil
   172  	}
   173  
   174  	// TODO(vadimsh): Optimize multi-group case.
   175  	for _, gr := range groups {
   176  		switch db.groups.IsMember(id, gr) {
   177  		case graph.IdentIsMember:
   178  			return true, nil
   179  		case graph.GroupIsUnknown:
   180  			logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev)
   181  		}
   182  	}
   183  	return false, nil
   184  }
   185  
   186  // CheckMembership returns groups from the given list the identity belongs to.
   187  //
   188  // Unlike IsMember, it doesn't stop on the first hit but continues evaluating
   189  // all groups.
   190  //
   191  // Unknown groups are considered empty. The order of groups in the result may
   192  // be different from the order in 'groups'.
   193  //
   194  // May return errors if underlying datastore has issues.
   195  func (db *SnapshotDB) CheckMembership(ctx context.Context, id identity.Identity, groups []string) (out []string, err error) {
   196  	_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.CheckMembership",
   197  		attribute.StringSlice("cr.dev.groups", groups),
   198  	)
   199  	defer func() { tracing.End(span, err, attribute.StringSlice("cr.dev.outcome", out)) }()
   200  
   201  	if db.groups == nil {
   202  		return
   203  	}
   204  
   205  	// TODO(vadimsh): Optimize multi-group case.
   206  	for _, gr := range groups {
   207  		switch db.groups.IsMember(id, gr) {
   208  		case graph.IdentIsMember:
   209  			out = append(out, gr)
   210  		case graph.GroupIsUnknown:
   211  			logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev)
   212  		}
   213  	}
   214  	return
   215  }
   216  
   217  // HasPermission returns true if the identity has the given permission in the
   218  // realm.
   219  func (db *SnapshotDB) HasPermission(ctx context.Context, id identity.Identity, perm realms.Permission, realm string, attrs realms.Attrs) (ok bool, err error) {
   220  	otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)),
   221  		attribute.String("cr.dev.permission", perm.Name()),
   222  		attribute.String("cr.dev.realm", realm),
   223  	)
   224  	for k, v := range attrs {
   225  		otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v))
   226  	}
   227  	ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.HasPermission",
   228  		otelAttrs...,
   229  	)
   230  	defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }()
   231  
   232  	// This may happen if the AuthDB proto has no Realms yet.
   233  	if db.realms == nil {
   234  		return false, errors.Reason("Realms API is not available").Err()
   235  	}
   236  
   237  	permIdx, ok := db.realms.PermissionIndex(perm)
   238  	if !ok {
   239  		logging.Warningf(ctx, "Checking permission %q not present in the AuthDB", perm)
   240  		return false, nil
   241  	}
   242  
   243  	// Verify such realm is defined in the DB or fallback to its @root.
   244  	if !db.realms.HasRealm(realm) {
   245  		if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil {
   246  			return false, errors.Annotate(err, "when checking %q", perm).Err()
   247  		}
   248  		project, name := realms.Split(realm)
   249  		root := realms.Join(project, realms.RootRealm)
   250  		if realm == root {
   251  			logging.Warningf(ctx, "Checking %q in a non-existing root realm %q: denying", perm, realm)
   252  			return false, nil
   253  		}
   254  		if !db.realms.HasRealm(root) {
   255  			logging.Warningf(ctx, "Checking %q in a non-existing realm %q that doesn't have a root realm (no such project?): denying", perm, realm)
   256  			return false, nil
   257  		}
   258  		// Don't log @legacy => @root fallbacks, they are semi-expected.
   259  		if name != realms.LegacyRealm {
   260  			logging.Warningf(ctx, "Checking %q in a non-existing realm %q: falling back to the root realm %q", perm, realm, root)
   261  		}
   262  		realm = root
   263  	}
   264  
   265  	// Grab the list of bindings for this permission and check if any applies to
   266  	// the `id` based on its group memberships.
   267  	q := db.groups.MembershipsQueryCache(id)
   268  	return db.realms.Bindings(realm, permIdx).Check(ctx, &q, attrs), nil
   269  }
   270  
   271  // QueryRealms returns a list of realms where the identity has the given
   272  // permission.
   273  func (db *SnapshotDB) QueryRealms(ctx context.Context, id identity.Identity, perm realms.Permission, project string, attrs realms.Attrs) (out []string, err error) {
   274  	otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)),
   275  		attribute.String("cr.dev.permission", perm.Name()),
   276  		attribute.String("cr.dev.project", project),
   277  	)
   278  	for k, v := range attrs {
   279  		otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v))
   280  	}
   281  	ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.QueryRealms",
   282  		otelAttrs...,
   283  	)
   284  	// `out` list can be huge. Just report the number of realms.
   285  	defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(out))) }()
   286  
   287  	if project != "" {
   288  		if err := realms.ValidateProjectName(project); err != nil {
   289  			return nil, err
   290  		}
   291  	}
   292  
   293  	// This may happen if the AuthDB proto has no Realms yet.
   294  	if db.realms == nil {
   295  		return nil, errors.Reason("Realms API is not available").Err()
   296  	}
   297  
   298  	permIdx, ok := db.realms.PermissionIndex(perm)
   299  	if !ok {
   300  		logging.Warningf(ctx, "Querying realms with permission %q not present in the AuthDB", perm)
   301  		return nil, nil
   302  	}
   303  
   304  	// Get the map project => all bindings for the given permission there. This
   305  	// returns `ok == false` if the permission was not flagged with
   306  	// UsedInQueryRealms.
   307  	permBindings, ok := db.realms.QueryBindings(permIdx)
   308  	if !ok {
   309  		return nil, errors.Reason("permission %s cannot be used in QueryRealms: it was not flagged with UsedInQueryRealms flag", perm).Err()
   310  	}
   311  
   312  	// For each potentially matching list of bindings, check if it really matches.
   313  	q := db.groups.MembershipsQueryCache(id)
   314  	visit := func(bindings []realmset.RealmBindings) {
   315  		for _, realmBindings := range bindings {
   316  			if realmBindings.Bindings.Check(ctx, &q, attrs) {
   317  				out = append(out, realmBindings.Realm)
   318  			}
   319  		}
   320  	}
   321  	if project != "" {
   322  		visit(permBindings[project])
   323  	} else {
   324  		for _, bindings := range permBindings {
   325  			visit(bindings)
   326  		}
   327  	}
   328  
   329  	return out, nil
   330  }
   331  
   332  // FilterKnownGroups filters the list of groups keeping only ones that exist.
   333  //
   334  // May return errors if underlying datastore has issues. If all groups are
   335  // unknown, returns an empty list and no error.
   336  func (db *SnapshotDB) FilterKnownGroups(ctx context.Context, groups []string) (known []string, err error) {
   337  	_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.FilterKnownGroups",
   338  		attribute.Int("cr.dev.groups", len(groups)),
   339  	)
   340  	defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(known))) }()
   341  
   342  	if db.groups == nil {
   343  		return nil, nil
   344  	}
   345  
   346  	known = make([]string, 0, len(groups))
   347  	for _, gr := range groups {
   348  		if _, ok := db.groups.GroupIndex(gr); ok {
   349  			known = append(known, gr)
   350  		}
   351  	}
   352  	return known, nil
   353  }
   354  
   355  // GetCertificates returns a bundle with certificates of a trusted signer.
   356  //
   357  // Currently only the Token Server is a trusted signer.
   358  func (db *SnapshotDB) GetCertificates(ctx context.Context, signerID identity.Identity) (*signing.PublicCertificates, error) {
   359  	if db.tokenServiceURL == "" {
   360  		logging.Warningf(
   361  			ctx, "Delegation is not supported, the token server URL is not set by %s",
   362  			db.AuthServiceURL)
   363  		return nil, nil
   364  	}
   365  	switch tokenServerID, certs, err := db.tokenServiceCerts.GetCerts(ctx); {
   366  	case err != nil:
   367  		return nil, err
   368  	case signerID != tokenServerID:
   369  		return nil, nil // signerID is not trusted since it's not a token server
   370  	default:
   371  		return certs, nil
   372  	}
   373  }
   374  
   375  // GetAllowlistForIdentity returns name of the IP allowlist to use to check
   376  // IP of requests from the given `ident`.
   377  //
   378  // It's used to restrict access for certain account to certain IP subnets.
   379  //
   380  // Returns ("", nil) if `ident` is not IP restricted.
   381  func (db *SnapshotDB) GetAllowlistForIdentity(ctx context.Context, ident identity.Identity) (string, error) {
   382  	return db.allowlistedIPs.GetAllowlistForIdentity(ident), nil
   383  }
   384  
   385  // IsAllowedIP returns true if IP address belongs to given named IP allowlist.
   386  func (db *SnapshotDB) IsAllowedIP(ctx context.Context, ip net.IP, allowlist string) (bool, error) {
   387  	return db.allowlistedIPs.IsAllowedIP(ip, allowlist), nil
   388  }
   389  
   390  // GetAuthServiceURL returns root URL ("https://<host>") of the auth service
   391  // the snapshot was fetched from.
   392  //
   393  // This is needed to implement authdb.DB interface.
   394  func (db *SnapshotDB) GetAuthServiceURL(ctx context.Context) (string, error) {
   395  	if db.AuthServiceURL == "" {
   396  		return "", errors.Reason("not using Auth Service").Err()
   397  	}
   398  	return db.AuthServiceURL, nil
   399  }
   400  
   401  // GetTokenServiceURL returns root URL ("https://<host>") of the token server.
   402  //
   403  // This is needed to implement authdb.DB interface.
   404  func (db *SnapshotDB) GetTokenServiceURL(ctx context.Context) (string, error) {
   405  	return db.tokenServiceURL, nil
   406  }
   407  
   408  // GetRealmData returns data attached to a realm.
   409  func (db *SnapshotDB) GetRealmData(ctx context.Context, realm string) (*protocol.RealmData, error) {
   410  	// This may happen if the AuthDB proto has no Realms yet.
   411  	if db.realms == nil {
   412  		return nil, errors.Reason("Realms API is not available").Err()
   413  	}
   414  
   415  	// Verify such realm is defined in the DB or fallback to its @root.
   416  	if !db.realms.HasRealm(realm) {
   417  		if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil {
   418  			return nil, err
   419  		}
   420  		project, _ := realms.Split(realm)
   421  		root := realms.Join(project, realms.RootRealm)
   422  		if realm == root || !db.realms.HasRealm(root) {
   423  			return nil, nil // no such project or it doesn't have realms.cfg
   424  		}
   425  		realm = root
   426  	}
   427  
   428  	data := db.realms.Data(realm)
   429  	if data == nil {
   430  		data = &protocol.RealmData{}
   431  	}
   432  	return data, nil
   433  }