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 }