go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/internal/git/gitacls/acls.go (about) 1 // Copyright 2018 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 gitacls implements read ACLs for Git/Gerrit data. 16 package gitacls 17 18 import ( 19 "context" 20 "net/url" 21 "sort" 22 "strings" 23 24 "go.chromium.org/luci/auth/identity" 25 "go.chromium.org/luci/common/data/stringset" 26 "go.chromium.org/luci/common/errors" 27 "go.chromium.org/luci/common/retry/transient" 28 "go.chromium.org/luci/config/validation" 29 configpb "go.chromium.org/luci/milo/proto/config" 30 "go.chromium.org/luci/server/auth" 31 ) 32 33 // FromConfig returns ACLs if config is valid. 34 func FromConfig(c context.Context, cfg []*configpb.Settings_SourceAcls) (*ACLs, error) { 35 ctx := validation.Context{Context: c} 36 ACLs := &ACLs{} 37 ACLs.load(&ctx, cfg) 38 if err := ctx.Finalize(); err != nil { 39 return nil, err 40 } 41 return ACLs, nil 42 } 43 44 // ValidateConfig passes all validation errors through validation context. 45 func ValidateConfig(ctx *validation.Context, cfg []*configpb.Settings_SourceAcls) { 46 var a ACLs 47 a.load(ctx, cfg) 48 } 49 50 // ACLs define readers for git repositories and Gerrits CLs. 51 type ACLs struct { 52 hosts map[string]*hostACLs // immutable once constructed. 53 } 54 55 // IsAllowed returns whether current identity has been granted read access to 56 // given Git/Gerrit project. 57 func (a *ACLs) IsAllowed(c context.Context, host, project string) (bool, error) { 58 // Convert Gerrit to Git hosts. 59 if strings.HasSuffix(host, "-review.googlesource.com") { 60 host = strings.TrimSuffix(host, "-review.googlesource.com") + ".googlesource.com" 61 } 62 hacls, configured := a.hosts[host] 63 if !configured { 64 return false, nil 65 } 66 if pacls, projKnown := hacls.projects[project]; projKnown { 67 return a.belongsTo(c, hacls.readers, pacls.readers) 68 } 69 return a.belongsTo(c, hacls.readers) 70 } 71 72 // private implementation. 73 74 func (a *ACLs) belongsTo(c context.Context, readerGroups ...[]string) (bool, error) { 75 currentIdentity := auth.CurrentIdentity(c) 76 groups := []string{} 77 for _, readers := range readerGroups { 78 for _, r := range readers { 79 switch { 80 case strings.HasPrefix(r, "group:"): 81 // auth.IsMember expects group names w/o "group:" prefix. 82 groups = append(groups, r[len("group:"):]) 83 case currentIdentity == identity.Identity(r): 84 return true, nil 85 } 86 } 87 } 88 isMember, err := auth.IsMember(c, groups...) 89 if err != nil { 90 return false, transient.Tag.Apply(err) 91 } 92 return isMember, nil 93 } 94 95 type itemACLs struct { 96 definedIn int // used for validation only. 97 readers []string 98 } 99 100 type hostACLs struct { 101 itemACLs 102 projects map[string]*itemACLs // key is project name starting with '/'. 103 } 104 105 // load validates and loads ACLs from config. 106 func (a *ACLs) load(ctx *validation.Context, cfg []*configpb.Settings_SourceAcls) { 107 a.hosts = map[string]*hostACLs{} 108 for i, group := range cfg { 109 ctx.Enter("source_acls #%d", i) 110 111 if len(group.Readers) == 0 { 112 ctx.Errorf("at least 1 reader required") 113 } 114 if len(group.Hosts)+len(group.Projects) == 0 { 115 ctx.Errorf("at least 1 host or project required") 116 } 117 118 gReaders := a.loadReaders(ctx, group.Readers) 119 for _, hostURL := range group.Hosts { 120 ctx.Enter("host %q", hostURL) 121 a.loadHost(ctx, i, hostURL, gReaders) 122 ctx.Exit() 123 } 124 for _, projectURL := range group.Projects { 125 ctx.Enter("project %q", projectURL) 126 a.loadProject(ctx, i, projectURL, gReaders) 127 ctx.Exit() 128 } 129 ctx.Exit() 130 } 131 } 132 133 // loadReaders validates readers and returns slice of valid identities. 134 func (a *ACLs) loadReaders(ctx *validation.Context, readers []string) []string { 135 known := stringset.New(len(readers)) 136 for _, r := range readers { 137 id := r 138 switch { 139 case strings.HasPrefix(r, "group:"): 140 if r[len("group:"):] == "" { 141 ctx.Errorf("invalid readers %q: needs a group name", r) 142 } 143 case !strings.ContainsRune(r, ':'): 144 id = "user:" + r 145 fallthrough 146 default: 147 if _, err := identity.MakeIdentity(id); err != nil { 148 ctx.Error(errors.Annotate(err, "invalid readers %q", r).Err()) 149 continue 150 } 151 } 152 if !known.Add(id) { 153 ctx.Errorf("duplicate readers %q", r) 154 } 155 } 156 res := known.ToSlice() 157 sort.Strings(res) 158 return res 159 } 160 161 func (a *ACLs) loadHost(ctx *validation.Context, blockID int, hostURL string, readers []string) { 162 u, valid := validateURL(ctx, hostURL) 163 if !valid && u == nil { 164 return // Can't validate any more. 165 } 166 u.Path = strings.TrimRight(u.Path, "/") 167 if u.Path != "" || u.Fragment != "" { 168 ctx.Errorf("shouldn't have path or fragment components") 169 valid = false 170 } 171 switch ha, dup := a.hosts[u.Host]; { 172 case dup && ha.definedIn > -1: 173 ctx.Errorf("has already been defined in source_acl #%d", ha.definedIn) 174 case !valid: 175 return 176 case dup: 177 ha.definedIn = blockID 178 ha.readers = readers 179 default: 180 a.hosts[u.Host] = &hostACLs{itemACLs: itemACLs{definedIn: blockID, readers: readers}} 181 } 182 } 183 func (a *ACLs) loadProject(ctx *validation.Context, blockID int, projectURL string, readers []string) { 184 u, valid := validateURL(ctx, projectURL) 185 if !valid && u == nil { 186 return // Can't validate any more. 187 } 188 u.Path = strings.TrimRight(u.Path, "/") 189 if u.Path == "" { 190 ctx.Errorf("should not be just a host") 191 valid = false 192 } 193 if strings.HasPrefix(u.Path, "/a/") { 194 ctx.Errorf("should not contain '/a' path prefix") 195 valid = false 196 } 197 if strings.HasSuffix(u.Path, ".git") { 198 ctx.Errorf("should not contain '.git'") 199 valid = false 200 } 201 if u.Fragment != "" { 202 ctx.Errorf("shouldn't have fragment components") 203 valid = false 204 } 205 if !valid { 206 return 207 } 208 209 u.Path = u.Path[1:] // trim starting '/'. 210 hACLs, knownHost := a.hosts[u.Host] 211 switch { 212 case knownHost && hACLs.definedIn == blockID: 213 ctx.Errorf("redundant because already covered by its host in the same source_acls block") 214 return 215 case !knownHost: 216 hACLs = &hostACLs{itemACLs: itemACLs{definedIn: -1}} 217 a.hosts[u.Host] = hACLs 218 fallthrough 219 case hACLs.projects == nil: 220 // Less optimal, but more readable. 221 hACLs.projects = make(map[string]*itemACLs, 1) 222 } 223 224 switch projACLs, known := hACLs.projects[u.Path]; { 225 case known && projACLs.definedIn == blockID: 226 ctx.Errorf("duplicate, already defined in the same source_acls block") 227 case known: 228 projACLs.definedIn = blockID 229 projACLs.readers = stringset.NewFromSlice(append(projACLs.readers, readers...)...).ToSlice() 230 sort.Strings(projACLs.readers) 231 default: 232 hACLs.projects[u.Path] = &itemACLs{definedIn: blockID, readers: readers} 233 } 234 } 235 236 // validateURL returns parsed URL and whether it has passed validation. 237 func validateURL(ctx *validation.Context, s string) (*url.URL, bool) { 238 // url.Parse considers "example.com/a/b" to be a path, so ensure a scheme. 239 if !strings.HasPrefix(s, "https://") { 240 s = "https://" + s 241 } 242 u, err := url.Parse(s) 243 if err != nil { 244 ctx.Error(errors.Annotate(err, "not a valid URL").Err()) 245 return nil, false 246 } 247 valid := true 248 if !strings.HasSuffix(u.Host, ".googlesource.com") { 249 ctx.Errorf("isn't at *.googlesource.com %q", u.Host) 250 valid = false 251 } 252 if u.Scheme != "" && u.Scheme != "https" { 253 ctx.Errorf("scheme must be https") 254 valid = false 255 } 256 if strings.HasSuffix(u.Host, "-review.googlesource.com") { 257 ctx.Errorf("must not be a Gerrit host (try without '-review')") 258 valid = false 259 } 260 return u, valid 261 }