blob: eff065ca45702e6cb328e5819f9d72e478dc0e67 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package perm implements permission checks.
//
// The API is formulated in terms of LUCI Realms permissions, but it is
// currently implemented on top of native Buildbucket roles (which are
// deprecated).
package perm
import (
"context"
"sort"
"sync"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/sync/parallel"
"go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/realms"
"go.chromium.org/luci/server/caching/layered"
"go.chromium.org/luci/buildbucket/appengine/model"
"go.chromium.org/luci/buildbucket/bbperms"
pb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/buildbucket/protoutil"
)
const (
// Administrators is a group of users that have all permissions in all
// buckets.
Administrators = "administrators"
)
// Cache "<project>/<bucket>" => wirepb-serialized pb.Bucket.
//
// Missing buckets are represented by empty pb.Bucket protos.
var bucketCache = layered.RegisterCache(layered.Parameters[*pb.Bucket]{
ProcessCacheCapacity: 65536,
GlobalNamespace: "bucket_cache_v1",
Marshal: func(item *pb.Bucket) ([]byte, error) {
return proto.Marshal(item)
},
Unmarshal: func(blob []byte) (*pb.Bucket, error) {
pb := &pb.Bucket{}
if err := proto.Unmarshal(blob, pb); err != nil {
return nil, err
}
return pb, nil
},
AllowNoProcessCacheFallback: true, // allow skipping cache in tests
})
// getBucket fetches a cached bucket proto.
//
// The returned value can be up to a minute stale compared to the state in
// the datastore.
//
// Returns:
//
// bucket, nil - on success.
// nil, nil - if the bucket is absent.
// nil, err - on internal errors.
func getBucket(ctx context.Context, project, bucket string) (*pb.Bucket, error) {
if project == "" {
return nil, errors.Reason("project name is empty").Err()
}
if bucket == "" {
return nil, errors.Reason("bucket name is empty").Err()
}
item, err := bucketCache.GetOrCreate(ctx, project+"/"+bucket, func() (*pb.Bucket, time.Duration, error) {
entity := &model.Bucket{ID: bucket, Parent: model.ProjectKey(ctx, project)}
switch err := datastore.Get(ctx, entity); {
case err == nil:
// We rely on Name to not be empty for existing buckets. Make sure it is
// not empty.
bucketPB := entity.Proto
if bucketPB == nil {
bucketPB = &pb.Bucket{}
}
bucketPB.Name = bucket
return bucketPB, time.Minute, nil
case err == datastore.ErrNoSuchEntity:
// Cache "absence" for a shorter duration to make it harder to overflow
// the cache with requests for non-existing buckets.
return &pb.Bucket{}, 15 * time.Second, nil
default:
return nil, 0, errors.Annotate(err, "datastore error").Err()
}
}, layered.WithRandomizedExpiration(10*time.Second))
if err != nil {
return nil, err
}
// Name is always populated in existing buckets. It is never populated in
// missing buckets.
if item.Name == "" {
return nil, nil
}
return item, nil
}
// HasInBucket checks the caller has the given permission in the bucket.
//
// Returns appstatus errors. If the bucket doesn't exist returns NotFound.
//
// Always checks the read permission (represented by BuildersGet), returning
// NotFound if the caller doesn't have it. Returns PermissionDenied if the
// caller has the read permission, but not the requested `perm`.
func HasInBucket(ctx context.Context, perm realms.Permission, project, bucket string) error {
// Referring to a non-existing bucket is NotFound, even if the requested
// permission is available via the @root realm.
bucketPB, err := getBucket(ctx, project, bucket)
switch {
case err != nil:
return errors.Annotate(err, "failed to fetch bucket %q", project+"/"+bucket).Err()
case bucketPB == nil:
return NotFoundErr(ctx)
}
realm := realms.Join(project, bucket)
switch yes, err := auth.HasPermission(ctx, perm, realm, nil); {
case err != nil:
return errors.Annotate(err, "failed to check realm %q ACLs", realm).Err()
case yes:
return nil
}
// For compatibility with legacy ACLs, administrators have implicit access to
// everything. Log when this rule is invoked, since it's surprising and it
// something we might want to get rid of after everything is migrated to
// Realms.
switch is, err := auth.IsMember(ctx, Administrators); {
case err != nil:
return errors.Annotate(err, "failed to check group membership in %q", Administrators).Err()
case is:
logging.Warningf(ctx, "ADMIN_FALLBACK: perm=%q bucket=%q caller=%q",
perm, project+"/"+bucket, auth.CurrentIdentity(ctx))
return nil
}
// The user doesn't have the requested permission. Give a detailed error
// message only if the caller is allowed to see the builder. Otherwise return
// generic "Not found or no permission" error.
if perm != bbperms.BuildersGet {
switch visible, err := auth.HasPermission(ctx, bbperms.BuildersGet, realm, nil); {
case err != nil:
return errors.Annotate(err, "failed to check realm %q ACLs", realm).Err()
case visible:
return appstatus.Errorf(codes.PermissionDenied,
"%q does not have permission %q in bucket %q",
auth.CurrentIdentity(ctx), perm, project+"/"+bucket)
}
}
return NotFoundErr(ctx)
}
// HasInBuilder checks the caller has the given permission in the builder.
//
// It's just a tiny wrapper around HasInBucket to reduce typing.
func HasInBuilder(ctx context.Context, perm realms.Permission, id *pb.BuilderID) error {
return HasInBucket(ctx, perm, id.Project, id.Bucket)
}
// NotFoundErr returns an appstatus with a generic error message indicating
// the resource requested was not found with a hint that the user may not have
// permission to view it. By not differentiating between "not found" and
// "permission denied" errors, leaking existence of resources a user doesn't
// have permission to view can be avoided. Should be used everywhere a
// "not found" or "permission denied" error occurs.
func NotFoundErr(ctx context.Context) error {
return appstatus.Errorf(codes.NotFound, "requested resource not found or %q does not have permission to view it", auth.CurrentIdentity(ctx))
}
// BucketsByPerm returns buckets of the project that the caller has the given permission in.
// If the project is empty, it returns all user accessible buckets.
// Note: if the caller doesn't have the permission, it returns empty buckets.
func BucketsByPerm(ctx context.Context, p realms.Permission, project string) (buckets []string, err error) {
var projKey *datastore.Key
if project != "" {
projKey = datastore.KeyForObj(ctx, &model.Project{ID: project})
}
var bucketKeys []*datastore.Key
if err := datastore.GetAll(ctx, datastore.NewQuery(model.BucketKind).Ancestor(projKey), &bucketKeys); err != nil {
return nil, err
}
err = parallel.WorkPool(len(bucketKeys), func(c chan<- func() error) {
var mu sync.Mutex
for _, bk := range bucketKeys {
bk := bk
c <- func() error {
if err := HasInBucket(ctx, p, bk.Parent().StringID(), bk.StringID()); err != nil {
status, ok := appstatus.Get(err)
if ok && (status.Code() == codes.PermissionDenied || status.Code() == codes.NotFound) {
return nil
}
return err
}
mu.Lock()
buckets = append(buckets, protoutil.FormatBucketID(bk.Parent().StringID(), bk.StringID()))
mu.Unlock()
return nil
}
}
})
sort.Strings(buckets)
return
}
// hasInBuilderBoolean is a wrapper around HasInBuilder that handles denied/not-found errors
// and returns false instead of an error in those cases.
func hasInBuilderBoolean(ctx context.Context, p realms.Permission, builderID *pb.BuilderID) (bool, error) {
err := HasInBuilder(ctx, p, builderID)
if err == nil {
return true, nil
}
status, ok := appstatus.Get(err)
if ok && (status.Code() == codes.PermissionDenied || status.Code() == codes.NotFound) {
return false, nil
}
return false, err
}
// GetFirstAvailablePerm returns the first permission in the given list which is granted to
// the user for the given builder. Returns an error if the user has none of the permissions.
func GetFirstAvailablePerm(ctx context.Context, builderID *pb.BuilderID, perms ...realms.Permission) (realms.Permission, error) {
if len(perms) == 0 {
panic("at least one permission must be provided")
}
// Look at each permission in turn except for the last one.
for _, perm := range perms[:len(perms)-1] {
if ok, err := hasInBuilderBoolean(ctx, perm, builderID); err != nil {
return realms.Permission{}, err
} else if ok {
return perm, nil
}
}
// If the user doesn't have any permissions at all, we want to throw an error, so use
// HasInBuilder instead of the boolean version.
lastPerm := perms[len(perms)-1]
if err := HasInBuilder(ctx, lastPerm, builderID); err != nil {
return realms.Permission{}, err
}
return lastPerm, nil
}
// getCachedPerm is a simple caching wrapper to check whether the user has a permission in
// a given bucket cache (map of bucket names to broadest Build read permission).
//
// Returns an error if the user does not have at least bbperms.BuildsList.
func getCachedPerm(ctx context.Context, bucketPermCache map[string]realms.Permission, builderID *pb.BuilderID) (realms.Permission, error) {
qualifiedBucket := protoutil.FormatBucketID(builderID.GetProject(), builderID.GetBucket())
_, cached := bucketPermCache[qualifiedBucket]
if !cached {
broadestBuildReadPerm, err := GetFirstAvailablePerm(ctx, builderID, bbperms.BuildsGet, bbperms.BuildsGetLimited, bbperms.BuildsList)
if err != nil {
return realms.Permission{}, err
}
bucketPermCache[qualifiedBucket] = broadestBuildReadPerm
}
return bucketPermCache[qualifiedBucket], nil
}
// RedactBuild redacts fields from the given build based on whether the user has
// appropriate permissions to see those fields.
// The relevant permissions are:
//
// bbperms.BuildsGet: can see all fields
// bbperms.BuildsGetLimited: can see a limited set of fields excluding detailed builder output
// bbperms.BuildsList: can see only basic fields required to list builds
//
// Returns an error if the user does not have at least bbperms.BuildsList.
//
// For efficiency in the case where multiple builds are going to be redacted at once, the caller
// may optionally supply a bucket cache (map of bucket names to broadest Build read permission).
func RedactBuild(ctx context.Context, bucketPermCache map[string]realms.Permission, build *pb.Build) error {
if bucketPermCache == nil {
bucketPermCache = make(map[string]realms.Permission)
}
builderID := build.GetBuilder()
broadestPerm, err := getCachedPerm(ctx, bucketPermCache, builderID)
if err != nil {
return err
}
var redactionMask *model.BuildMask
switch {
case broadestPerm == bbperms.BuildsGet:
return nil
case broadestPerm == bbperms.BuildsGetLimited:
redactionMask = model.GetLimitedBuildMask
case broadestPerm == bbperms.BuildsList:
redactionMask = model.ListOnlyBuildMask
}
if err := redactionMask.Trim(build); err != nil {
return err
}
return nil
}