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 |