go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/buildbucket/appengine/internal/perm/perm.go (about)

     1  // Copyright 2020 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 perm implements permission checks.
    16  //
    17  // The API is formulated in terms of LUCI Realms permissions, but it is
    18  // currently implemented on top of native Buildbucket roles (which are
    19  // deprecated).
    20  package perm
    21  
    22  import (
    23  	"context"
    24  	"sort"
    25  	"sync"
    26  	"time"
    27  
    28  	"google.golang.org/grpc/codes"
    29  	"google.golang.org/protobuf/proto"
    30  
    31  	"go.chromium.org/luci/common/errors"
    32  	"go.chromium.org/luci/common/logging"
    33  	"go.chromium.org/luci/common/sync/parallel"
    34  	"go.chromium.org/luci/gae/service/datastore"
    35  	"go.chromium.org/luci/grpc/appstatus"
    36  	"go.chromium.org/luci/server/auth"
    37  	"go.chromium.org/luci/server/auth/realms"
    38  	"go.chromium.org/luci/server/caching/layered"
    39  
    40  	"go.chromium.org/luci/buildbucket/appengine/model"
    41  	"go.chromium.org/luci/buildbucket/bbperms"
    42  	pb "go.chromium.org/luci/buildbucket/proto"
    43  	"go.chromium.org/luci/buildbucket/protoutil"
    44  )
    45  
    46  const (
    47  	// Administrators is a group of users that have all permissions in all
    48  	// buckets.
    49  	Administrators = "administrators"
    50  )
    51  
    52  // Cache "<project>/<bucket>" => wirepb-serialized pb.Bucket.
    53  //
    54  // Missing buckets are represented by empty pb.Bucket protos.
    55  var bucketCache = layered.RegisterCache(layered.Parameters[*pb.Bucket]{
    56  	ProcessCacheCapacity: 65536,
    57  	GlobalNamespace:      "bucket_cache_v1",
    58  	Marshal: func(item *pb.Bucket) ([]byte, error) {
    59  		return proto.Marshal(item)
    60  	},
    61  	Unmarshal: func(blob []byte) (*pb.Bucket, error) {
    62  		pb := &pb.Bucket{}
    63  		if err := proto.Unmarshal(blob, pb); err != nil {
    64  			return nil, err
    65  		}
    66  		return pb, nil
    67  	},
    68  	AllowNoProcessCacheFallback: true, // allow skipping cache in tests
    69  })
    70  
    71  // getBucket fetches a cached bucket proto.
    72  //
    73  // The returned value can be up to a minute stale compared to the state in
    74  // the datastore.
    75  //
    76  // Returns:
    77  //
    78  //	bucket, nil - on success.
    79  //	nil, nil - if the bucket is absent.
    80  //	nil, err - on internal errors.
    81  func getBucket(ctx context.Context, project, bucket string) (*pb.Bucket, error) {
    82  	if project == "" {
    83  		return nil, errors.Reason("project name is empty").Err()
    84  	}
    85  	if bucket == "" {
    86  		return nil, errors.Reason("bucket name is empty").Err()
    87  	}
    88  
    89  	item, err := bucketCache.GetOrCreate(ctx, project+"/"+bucket, func() (*pb.Bucket, time.Duration, error) {
    90  		entity := &model.Bucket{ID: bucket, Parent: model.ProjectKey(ctx, project)}
    91  		switch err := datastore.Get(ctx, entity); {
    92  		case err == nil:
    93  			// We rely on Name to not be empty for existing buckets. Make sure it is
    94  			// not empty.
    95  			bucketPB := entity.Proto
    96  			if bucketPB == nil {
    97  				bucketPB = &pb.Bucket{}
    98  			}
    99  			bucketPB.Name = bucket
   100  			return bucketPB, time.Minute, nil
   101  		case err == datastore.ErrNoSuchEntity:
   102  			// Cache "absence" for a shorter duration to make it harder to overflow
   103  			// the cache with requests for non-existing buckets.
   104  			return &pb.Bucket{}, 15 * time.Second, nil
   105  		default:
   106  			return nil, 0, errors.Annotate(err, "datastore error").Err()
   107  		}
   108  	}, layered.WithRandomizedExpiration(10*time.Second))
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	// Name is always populated in existing buckets. It is never populated in
   114  	// missing buckets.
   115  	if item.Name == "" {
   116  		return nil, nil
   117  	}
   118  	return item, nil
   119  }
   120  
   121  // HasInBucket checks the caller has the given permission in the bucket.
   122  //
   123  // Returns appstatus errors. If the bucket doesn't exist returns NotFound.
   124  //
   125  // Always checks the read permission (represented by BuildersGet), returning
   126  // NotFound if the caller doesn't have it. Returns PermissionDenied if the
   127  // caller has the read permission, but not the requested `perm`.
   128  func HasInBucket(ctx context.Context, perm realms.Permission, project, bucket string) error {
   129  	// Referring to a non-existing bucket is NotFound, even if the requested
   130  	// permission is available via the @root realm.
   131  	bucketPB, err := getBucket(ctx, project, bucket)
   132  	switch {
   133  	case err != nil:
   134  		return errors.Annotate(err, "failed to fetch bucket %q", project+"/"+bucket).Err()
   135  	case bucketPB == nil:
   136  		return NotFoundErr(ctx)
   137  	}
   138  
   139  	realm := realms.Join(project, bucket)
   140  	switch yes, err := auth.HasPermission(ctx, perm, realm, nil); {
   141  	case err != nil:
   142  		return errors.Annotate(err, "failed to check realm %q ACLs", realm).Err()
   143  	case yes:
   144  		return nil
   145  	}
   146  
   147  	// For compatibility with legacy ACLs, administrators have implicit access to
   148  	// everything. Log when this rule is invoked, since it's surprising and it
   149  	// something we might want to get rid of after everything is migrated to
   150  	// Realms.
   151  	switch is, err := auth.IsMember(ctx, Administrators); {
   152  	case err != nil:
   153  		return errors.Annotate(err, "failed to check group membership in %q", Administrators).Err()
   154  	case is:
   155  		logging.Warningf(ctx, "ADMIN_FALLBACK: perm=%q bucket=%q caller=%q",
   156  			perm, project+"/"+bucket, auth.CurrentIdentity(ctx))
   157  		return nil
   158  	}
   159  
   160  	// The user doesn't have the requested permission. Give a detailed error
   161  	// message only if the caller is allowed to see the builder. Otherwise return
   162  	// generic "Not found or no permission" error.
   163  	if perm != bbperms.BuildersGet {
   164  		switch visible, err := auth.HasPermission(ctx, bbperms.BuildersGet, realm, nil); {
   165  		case err != nil:
   166  			return errors.Annotate(err, "failed to check realm %q ACLs", realm).Err()
   167  		case visible:
   168  			return appstatus.Errorf(codes.PermissionDenied,
   169  				"%q does not have permission %q in bucket %q",
   170  				auth.CurrentIdentity(ctx), perm, project+"/"+bucket)
   171  		}
   172  	}
   173  
   174  	return NotFoundErr(ctx)
   175  }
   176  
   177  // HasInBuilder checks the caller has the given permission in the builder.
   178  //
   179  // It's just a tiny wrapper around HasInBucket to reduce typing.
   180  func HasInBuilder(ctx context.Context, perm realms.Permission, id *pb.BuilderID) error {
   181  	return HasInBucket(ctx, perm, id.Project, id.Bucket)
   182  }
   183  
   184  // NotFoundErr returns an appstatus with a generic error message indicating
   185  // the resource requested was not found with a hint that the user may not have
   186  // permission to view it. By not differentiating between "not found" and
   187  // "permission denied" errors, leaking existence of resources a user doesn't
   188  // have permission to view can be avoided. Should be used everywhere a
   189  // "not found" or "permission denied" error occurs.
   190  func NotFoundErr(ctx context.Context) error {
   191  	return appstatus.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to view it", auth.CurrentIdentity(ctx))
   192  }
   193  
   194  // BucketsByPerm returns buckets of the project that the caller has the given permission in.
   195  // If the project is empty, it returns all user accessible buckets.
   196  // Note: if the caller doesn't have the permission, it returns empty buckets.
   197  func BucketsByPerm(ctx context.Context, p realms.Permission, project string) (buckets []string, err error) {
   198  	var projKey *datastore.Key
   199  	if project != "" {
   200  		projKey = datastore.KeyForObj(ctx, &model.Project{ID: project})
   201  	}
   202  
   203  	var bucketKeys []*datastore.Key
   204  	if err := datastore.GetAll(ctx, datastore.NewQuery(model.BucketKind).Ancestor(projKey), &bucketKeys); err != nil {
   205  		return nil, err
   206  	}
   207  
   208  	err = parallel.WorkPool(len(bucketKeys), func(c chan<- func() error) {
   209  		var mu sync.Mutex
   210  		for _, bk := range bucketKeys {
   211  			bk := bk
   212  			c <- func() error {
   213  				if err := HasInBucket(ctx, p, bk.Parent().StringID(), bk.StringID()); err != nil {
   214  					status, ok := appstatus.Get(err)
   215  					if ok && (status.Code() == codes.PermissionDenied || status.Code() == codes.NotFound) {
   216  						return nil
   217  					}
   218  					return err
   219  				}
   220  				mu.Lock()
   221  				buckets = append(buckets, protoutil.FormatBucketID(bk.Parent().StringID(), bk.StringID()))
   222  				mu.Unlock()
   223  				return nil
   224  			}
   225  		}
   226  	})
   227  	sort.Strings(buckets)
   228  	return
   229  }
   230  
   231  // hasInBuilderBoolean is a wrapper around HasInBuilder that handles denied/not-found errors
   232  // and returns false instead of an error in those cases.
   233  func hasInBuilderBoolean(ctx context.Context, p realms.Permission, builderID *pb.BuilderID) (bool, error) {
   234  	err := HasInBuilder(ctx, p, builderID)
   235  	if err == nil {
   236  		return true, nil
   237  	}
   238  	status, ok := appstatus.Get(err)
   239  	if ok && (status.Code() == codes.PermissionDenied || status.Code() == codes.NotFound) {
   240  		return false, nil
   241  	}
   242  	return false, err
   243  }
   244  
   245  // GetFirstAvailablePerm returns the first permission in the given list which is granted to
   246  // the user for the given builder. Returns an error if the user has none of the permissions.
   247  func GetFirstAvailablePerm(ctx context.Context, builderID *pb.BuilderID, perms ...realms.Permission) (realms.Permission, error) {
   248  	if len(perms) == 0 {
   249  		panic("at least one permission must be provided")
   250  	}
   251  	// Look at each permission in turn except for the last one.
   252  	for _, perm := range perms[:len(perms)-1] {
   253  		if ok, err := hasInBuilderBoolean(ctx, perm, builderID); err != nil {
   254  			return realms.Permission{}, err
   255  		} else if ok {
   256  			return perm, nil
   257  		}
   258  	}
   259  
   260  	// If the user doesn't have any permissions at all, we want to throw an error, so use
   261  	// HasInBuilder instead of the boolean version.
   262  	lastPerm := perms[len(perms)-1]
   263  	if err := HasInBuilder(ctx, lastPerm, builderID); err != nil {
   264  		return realms.Permission{}, err
   265  	}
   266  	return lastPerm, nil
   267  }
   268  
   269  // getCachedPerm is a simple caching wrapper to check whether the user has a permission in
   270  // a given bucket cache (map of bucket names to broadest Build read permission).
   271  //
   272  // Returns an error if the user does not have at least bbperms.BuildsList.
   273  func getCachedPerm(ctx context.Context, bucketPermCache map[string]realms.Permission, builderID *pb.BuilderID) (realms.Permission, error) {
   274  	qualifiedBucket := protoutil.FormatBucketID(builderID.GetProject(), builderID.GetBucket())
   275  	_, cached := bucketPermCache[qualifiedBucket]
   276  	if !cached {
   277  		broadestBuildReadPerm, err := GetFirstAvailablePerm(ctx, builderID, bbperms.BuildsGet, bbperms.BuildsGetLimited, bbperms.BuildsList)
   278  		if err != nil {
   279  			return realms.Permission{}, err
   280  		}
   281  		bucketPermCache[qualifiedBucket] = broadestBuildReadPerm
   282  	}
   283  	return bucketPermCache[qualifiedBucket], nil
   284  }
   285  
   286  // RedactBuild redacts fields from the given build based on whether the user has
   287  // appropriate permissions to see those fields.
   288  // The relevant permissions are:
   289  //
   290  //	bbperms.BuildsGet: can see all fields
   291  //	bbperms.BuildsGetLimited: can see a limited set of fields excluding detailed builder output
   292  //	bbperms.BuildsList: can see only basic fields required to list builds
   293  //
   294  // Returns an error if the user does not have at least bbperms.BuildsList.
   295  //
   296  // For efficiency in the case where multiple builds are going to be redacted at once, the caller
   297  // may optionally supply a bucket cache (map of bucket names to broadest Build read permission).
   298  func RedactBuild(ctx context.Context, bucketPermCache map[string]realms.Permission, build *pb.Build) error {
   299  	if bucketPermCache == nil {
   300  		bucketPermCache = make(map[string]realms.Permission)
   301  	}
   302  	builderID := build.GetBuilder()
   303  	broadestPerm, err := getCachedPerm(ctx, bucketPermCache, builderID)
   304  	if err != nil {
   305  		return err
   306  	}
   307  	var redactionMask *model.BuildMask
   308  	switch {
   309  	case broadestPerm == bbperms.BuildsGet:
   310  		return nil
   311  	case broadestPerm == bbperms.BuildsGetLimited:
   312  		redactionMask = model.GetLimitedBuildMask
   313  	case broadestPerm == bbperms.BuildsList:
   314  		redactionMask = model.ListOnlyBuildMask
   315  	}
   316  	if err := redactionMask.Trim(build); err != nil {
   317  		return err
   318  	}
   319  	return nil
   320  }