| OLD | NEW |
| (Empty) |
| 1 // Copyright 2015 The Chromium Authors. All rights reserved. | |
| 2 // Use of this source code is governed by a BSD-style license that can be | |
| 3 // found in the LICENSE file. | |
| 4 | |
| 5 package auth | |
| 6 | |
| 7 import ( | |
| 8 "encoding/json" | |
| 9 "errors" | |
| 10 "fmt" | |
| 11 "io/ioutil" | |
| 12 "net/http" | |
| 13 "strings" | |
| 14 "time" | |
| 15 | |
| 16 "infra/libs/logging" | |
| 17 ) | |
| 18 | |
| 19 // ServiceURL is URL of a service to talk to by default. | |
| 20 const ServiceURL string = "https://chrome-infra-auth.appspot.com" | |
| 21 | |
| 22 // IdentityKind is enum like type with possible kinds of identities. | |
| 23 type IdentityKind string | |
| 24 | |
| 25 const ( | |
| 26 // IdentityKindUnknown is used when server return identity kind not reco
gnized | |
| 27 // by the client. | |
| 28 IdentityKindUnknown IdentityKind = "" | |
| 29 // IdentityKindAnonymous is used to represent anonymous callers. | |
| 30 IdentityKindAnonymous IdentityKind = "anonymous" | |
| 31 // IdentityKindBot is used to represent bots. Identity name is bot's IP. | |
| 32 IdentityKindBot IdentityKind = "bot" | |
| 33 // IdentityKindService is used to represent AppEngine apps when they use | |
| 34 // X-Appengine-Inbound-AppId header to authenticate. Identity name is ap
p ID. | |
| 35 IdentityKindService IdentityKind = "service" | |
| 36 // IdentityKindUser is used to represent end users or OAuth service acco
unts. | |
| 37 // Identity name is an associated email (user email or service account e
mail). | |
| 38 IdentityKindUser IdentityKind = "user" | |
| 39 ) | |
| 40 | |
| 41 var ( | |
| 42 // ErrAccessDenied is returned by GroupsService methods if caller doesn'
t have | |
| 43 // enough permissions to execute an action. Corresponds to 403 HTTP stat
us. | |
| 44 ErrAccessDenied = errors.New("Access denied (HTTP 403)") | |
| 45 | |
| 46 // ErrNoSuchItem is returned by GroupsService methods if requested item | |
| 47 // (e.g. a group) doesn't exist. Corresponds to 404 HTTP status. | |
| 48 ErrNoSuchItem = errors.New("No such item (HTTP 404)") | |
| 49 ) | |
| 50 | |
| 51 // Identity represents some caller that can make requests. It generalizes | |
| 52 // accounts of real people, bot accounts and service-to-service accounts. | |
| 53 type Identity struct { | |
| 54 // Kind describes what sort of identity this struct represents. | |
| 55 Kind IdentityKind | |
| 56 // Name defines concrete instance of identity, its meaning depends on ki
nd. | |
| 57 Name string | |
| 58 } | |
| 59 | |
| 60 // Group is a named list of identities, included subgroups and glob-like | |
| 61 // patterns (e.g. "user:*@example.com). | |
| 62 type Group struct { | |
| 63 // Name is a name of the group. | |
| 64 Name string | |
| 65 // Description is a human readable description of the group. | |
| 66 Description string | |
| 67 // Members is a list of identities included in the group explicitly. | |
| 68 Members []Identity | |
| 69 // Globs is a list of glob-like patterns for identities. | |
| 70 Globs []string | |
| 71 // Nested is a list of group names included into this group. | |
| 72 Nested []string | |
| 73 } | |
| 74 | |
| 75 // String returns human readable representation of Identity. | |
| 76 func (i Identity) String() string { | |
| 77 switch i.Kind { | |
| 78 case IdentityKindUnknown: | |
| 79 return "unknown" | |
| 80 case IdentityKindAnonymous: | |
| 81 return "anonymous" | |
| 82 case IdentityKindUser: | |
| 83 return i.Name | |
| 84 default: | |
| 85 return fmt.Sprintf("%s:%s", i.Kind, i.Name) | |
| 86 } | |
| 87 } | |
| 88 | |
| 89 // GroupsService knows how to talk to Groups API backend. Server side code | |
| 90 // is in https://github.com/luci/luci-py repository. | |
| 91 type GroupsService struct { | |
| 92 client *http.Client | |
| 93 serviceURL string | |
| 94 logger logging.Logger | |
| 95 } | |
| 96 | |
| 97 // NewGroupsService constructs new instance of GroupsService that talks to given | |
| 98 // service URL via given http.Client. If url is empty string, the default | |
| 99 // backend will be used. If httpClient is nil, http.DefaultClient will be used. | |
| 100 func NewGroupsService(url string, client *http.Client, logger logging.Logger) *G
roupsService { | |
| 101 if url == "" { | |
| 102 url = ServiceURL | |
| 103 } | |
| 104 if client == nil { | |
| 105 client = http.DefaultClient | |
| 106 } | |
| 107 if logger == nil { | |
| 108 logger = logging.Null() | |
| 109 } | |
| 110 return &GroupsService{ | |
| 111 client: client, | |
| 112 serviceURL: url, | |
| 113 logger: logger, | |
| 114 } | |
| 115 } | |
| 116 | |
| 117 // ServiceURL returns a string with root URL of a Groups backend. | |
| 118 func (s *GroupsService) ServiceURL() string { | |
| 119 return s.serviceURL | |
| 120 } | |
| 121 | |
| 122 // FetchCallerIdentity returns caller's own Identity as seen by the server. | |
| 123 func (s *GroupsService) FetchCallerIdentity() (Identity, error) { | |
| 124 var response struct { | |
| 125 Identity string `json:"identity"` | |
| 126 } | |
| 127 err := s.doGet("/auth/api/v1/accounts/self", &response) | |
| 128 if err != nil { | |
| 129 return Identity{}, err | |
| 130 } | |
| 131 return parseIdentity(response.Identity) | |
| 132 } | |
| 133 | |
| 134 // FetchGroup returns a group definition. It does not fetch nested groups. | |
| 135 // Returns ErrNoSuchItem error if no such group. | |
| 136 func (s *GroupsService) FetchGroup(name string) (Group, error) { | |
| 137 var response struct { | |
| 138 Group struct { | |
| 139 Name string `json:"name"` | |
| 140 Description string `json:"description"` | |
| 141 Members []string `json:"members"` | |
| 142 Globs []string `json:"globs"` | |
| 143 Nested []string `json:"nested"` | |
| 144 } `json:"group"` | |
| 145 } | |
| 146 err := s.doGet("/auth/api/v1/groups/"+name, &response) | |
| 147 if err != nil { | |
| 148 return Group{}, err | |
| 149 } | |
| 150 if response.Group.Name != name { | |
| 151 return Group{}, fmt.Errorf( | |
| 152 "Unexpected group name in server response: '%s', expecti
ng '%s'", | |
| 153 response.Group.Name, name) | |
| 154 } | |
| 155 g := Group{ | |
| 156 Name: response.Group.Name, | |
| 157 Description: response.Group.Description, | |
| 158 Members: make([]Identity, 0, len(response.Group.Members)), | |
| 159 Globs: response.Group.Globs, | |
| 160 Nested: response.Group.Nested, | |
| 161 } | |
| 162 for _, str := range response.Group.Members { | |
| 163 ident, err := parseIdentity(str) | |
| 164 if err != nil { | |
| 165 s.logger.Warningf("auth: failed to parse an identity in
a group, ignoring it - %s", err) | |
| 166 } else { | |
| 167 g.Members = append(g.Members, ident) | |
| 168 } | |
| 169 } | |
| 170 return g, nil | |
| 171 } | |
| 172 | |
| 173 // doGet sends GET HTTP requests and decodes JSON into response. It retries | |
| 174 // multiple times on transient errors. | |
| 175 func (s *GroupsService) doGet(path string, response interface{}) error { | |
| 176 if len(path) == 0 || path[0] != '/' { | |
| 177 return fmt.Errorf("Path should start with '/': %s", path) | |
| 178 } | |
| 179 url := s.serviceURL + path | |
| 180 for attempt := 0; attempt < 5; attempt++ { | |
| 181 if attempt != 0 { | |
| 182 s.logger.Warningf("auth: retrying request to %s", url) | |
| 183 sleep(2 * time.Second) | |
| 184 } | |
| 185 req, err := http.NewRequest("GET", url, nil) | |
| 186 if err != nil { | |
| 187 return err | |
| 188 } | |
| 189 resp, err := doRequest(s.client, req) | |
| 190 if err != nil { | |
| 191 return err | |
| 192 } | |
| 193 // Success? | |
| 194 if resp.StatusCode < 300 { | |
| 195 defer resp.Body.Close() | |
| 196 return json.NewDecoder(resp.Body).Decode(response) | |
| 197 } | |
| 198 // Fatal error? | |
| 199 if resp.StatusCode >= 300 && resp.StatusCode < 500 { | |
| 200 defer resp.Body.Close() | |
| 201 switch resp.StatusCode { | |
| 202 case 403: | |
| 203 return ErrAccessDenied | |
| 204 case 404: | |
| 205 return ErrNoSuchItem | |
| 206 default: | |
| 207 body, _ := ioutil.ReadAll(resp.Body) | |
| 208 return fmt.Errorf("Unexpected reply (HTTP %d):\n
%s", resp.StatusCode, string(body)) | |
| 209 } | |
| 210 } | |
| 211 // Retry. | |
| 212 resp.Body.Close() | |
| 213 } | |
| 214 return fmt.Errorf("Request to %s failed after 5 attempts", url) | |
| 215 } | |
| 216 | |
| 217 // parseIdentity takes a string of form "<kind>:<name>" and returns Identity | |
| 218 // struct. | |
| 219 func parseIdentity(str string) (Identity, error) { | |
| 220 chunks := strings.Split(str, ":") | |
| 221 if len(chunks) != 2 { | |
| 222 return Identity{}, fmt.Errorf("Invalid identity string: '%s'", s
tr) | |
| 223 } | |
| 224 kind := IdentityKind(chunks[0]) | |
| 225 name := chunks[1] | |
| 226 switch kind { | |
| 227 case IdentityKindAnonymous: | |
| 228 if name != "anonymous" { | |
| 229 return Identity{}, fmt.Errorf("Invalid anonymous identit
y: '%s'", str) | |
| 230 } | |
| 231 return Identity{ | |
| 232 Kind: IdentityKindAnonymous, | |
| 233 Name: "anonymous", | |
| 234 }, nil | |
| 235 case IdentityKindBot, IdentityKindService, IdentityKindUser: | |
| 236 return Identity{ | |
| 237 Kind: kind, | |
| 238 Name: name, | |
| 239 }, nil | |
| 240 default: | |
| 241 return Identity{}, fmt.Errorf("Unrecognized identity kind: '%s'"
, str) | |
| 242 } | |
| 243 } | |
| 244 | |
| 245 // sleep is mocked in tests. | |
| 246 var sleep = time.Sleep | |
| 247 | |
| 248 // doRequest is mocked in tests. | |
| 249 var doRequest = func(c *http.Client, req *http.Request) (*http.Response, error)
{ | |
| 250 return c.Do(req) | |
| 251 } | |
| OLD | NEW |