mirrored from https://chromium.googlesource.com/infra/luci/luci-py
-
Notifications
You must be signed in to change notification settings - Fork 36
/
handlers_frontend.py
480 lines (400 loc) · 15.7 KB
/
handlers_frontend.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# Copyright 2014 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""This module defines Auth Server frontend url handlers."""
import os
import base64
import webapp2
from google.appengine.api import app_identity
from components import auth
from components import template
from components import utils
from components.auth import b64
from components.auth import model
from components.auth import tokens
from components.auth import version
from components.auth.proto import replication_pb2
from components.auth.ui import rest_api
from components.auth.ui import ui
import acl
import config
import gcs
import importer
import pubsub
import replication
# Importing for the side effect of registering the config validation hook.
import realms
# Path to search for jinja templates.
TEMPLATES_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'templates')
################################################################################
## UI handlers.
class WarmupHandler(webapp2.RequestHandler):
def get(self):
auth.warmup()
self.response.headers['Content-Type'] = 'text/plain; charset=utf-8'
self.response.write('ok')
class ConfigHandler(ui.UINavbarTabHandler):
"""Page with simple UI for service-global configuration."""
navbar_tab_url = '/auth/config'
navbar_tab_id = 'config'
navbar_tab_title = 'Config'
# config.js here won't work because there's global JS var 'config' already.
js_file_url = '/auth_service/static/js/config_page.js'
template_file = 'auth_service/config.html'
class ServicesHandler(ui.UINavbarTabHandler):
"""Page with management UI for linking services."""
navbar_tab_url = '/auth/services'
navbar_tab_id = 'services'
navbar_tab_title = 'Services'
js_file_url = '/auth_service/static/js/services.js'
template_file = 'auth_service/services.html'
def get_additional_ui_data():
"""Gets injected into Jinja and Javascript environment."""
if not config.is_remote_configured():
return {'auth_service_config_locked': False}
config_revisions = {}
for path, rev in config.get_revisions().items():
config_revisions[path] = {
'rev': rev.revision if rev else 'none',
'url': rev.url if rev else 'about:blank',
}
return {
'auth_service_config_locked': True,
'auth_service_configs': {
'remote_url': config.get_remote_url(),
'revisions': config_revisions,
},
}
################################################################################
## API handlers.
class LinkTicketToken(auth.TokenKind):
"""Parameters for ServiceLinkTicket.ticket token."""
expiration_sec = 24 * 3600
secret_key = auth.SecretKey('link_ticket_token')
version = 1
class AuthDBRevisionsHandler(auth.ApiHandler):
"""Serves deflated AuthDB proto message with snapshot of all groups.
Args:
rev: version of the snapshot to get ('latest' or concrete revision number).
Not all versions may be available (i.e. there may be gaps in revision
numbers).
skip_body: if '1' will not return actual snapshot, just its SHA256 hash,
revision number and timestamp.
"""
@auth.require(lambda: (
auth.is_admin() or
acl.is_trusted_service() or
replication.is_replica(auth.get_current_identity())))
def get(self, rev):
skip_body = self.request.get('skip_body') == '1'
if rev == 'latest':
snapshot = replication.get_latest_auth_db_snapshot(skip_body)
else:
try:
rev = int(rev)
except ValueError:
self.abort_with_error(400, text='Bad revision number, not an integer')
snapshot = replication.get_auth_db_snapshot(rev, skip_body)
if not snapshot:
self.abort_with_error(404, text='No such snapshot: %s' % rev)
resp = {
'auth_db_rev': snapshot.key.integer_id(),
'created_ts': utils.datetime_to_timestamp(snapshot.created_ts),
'sha256': snapshot.auth_db_sha256,
}
if not skip_body:
assert snapshot.auth_db_deflated
resp['deflated_body'] = base64.b64encode(snapshot.auth_db_deflated)
self.send_response({'snapshot': resp})
class AuthDBSubscriptionAuthHandler(auth.ApiHandler):
"""Manages authorization to AuthDB PubSub topic and Google Storage object.
Members of 'auth-trusted-services' group may use this endpoint to make sure
they:
1. Can subscribe to AuthDB change notification PubSub topic.
2. Read Google Storage object that contains AuthDB dump.
"""
def caller_email(self):
"""Validates caller is using email for auth, returns it.
Raises HTTP 400 if some other kind of authentication is used.
"""
caller = auth.get_current_identity()
if not caller.is_user:
self.abort_with_error(400, text='Caller must use email-based auth')
return caller.name
@auth.require(acl.is_trusted_service)
def get(self):
"""Queries whether the caller is authorized to access AuthDB already.
Response body:
{
'topic': <full name of PubSub topic with AuthDB change notifications>,
'authorized': <true if the caller is allowed to subscribe to it>,
'gs': {
'auth_db_gs_path': <same as auth_db_gs_path in SettingsCfg proto>,
'authorized': <true if the caller should be able to read GS files>
}
}
"""
try:
return self.send_response({
'topic': pubsub.topic_name(),
'authorized': pubsub.is_authorized_subscriber(self.caller_email()),
'gs': {
'auth_db_gs_path': config.get_settings().auth_db_gs_path,
'authorized': gcs.is_authorized_reader(self.caller_email()),
},
})
except (gcs.Error, pubsub.Error) as e:
self.abort_with_error(409, text=str(e))
@auth.require(acl.is_trusted_service)
def post(self):
"""Authorizes the caller to access AuthDB.
In particular grants the caller "pubsub.subscriber" role on the AuthDB
change notifications topic and adds the caller as Reader to the Google
Storage object that contains AuthDB.
Response body:
{
'topic': <full name of PubSub topic with AuthDB change notifications>,
'authorized': true,
'gs': {
'auth_db_gs_path': <same as auth_db_gs_path in SettingsCfg proto>,
'authorized': true
}
}
"""
try:
pubsub.authorize_subscriber(self.caller_email())
gcs.authorize_reader(self.caller_email())
return self.send_response({
'topic': pubsub.topic_name(),
'authorized': True,
'gs': {
'auth_db_gs_path': config.get_settings().auth_db_gs_path,
'authorized': True,
},
})
except (gcs.Error, pubsub.Error) as e:
self.abort_with_error(409, text=str(e))
@auth.require(acl.is_trusted_service)
def delete(self):
"""Revokes the authorization if it exists.
Response body:
{
'topic': <full name of PubSub topic with AuthDB change notifications>,
'authorized': false,
'gs': {
'auth_db_gs_path': <same as auth_db_gs_path in SettingsCfg proto>,
'authorized': false
}
}
"""
try:
pubsub.deauthorize_subscriber(self.caller_email())
gcs.deauthorize_reader(self.caller_email())
return self.send_response({
'topic': pubsub.topic_name(),
'authorized': False,
'gs': {
'auth_db_gs_path': config.get_settings().auth_db_gs_path,
'authorized': False,
},
})
except (gcs.Error, pubsub.Error) as e:
self.abort_with_error(409, text=str(e))
class ImporterConfigHandler(auth.ApiHandler):
"""Reads and sets configuration of the group importer."""
@auth.require(acl.has_access)
def get(self):
self.send_response({'config': importer.read_config()})
@auth.require(auth.is_admin)
def post(self):
if config.is_remote_configured():
self.abort_with_error(409, text='The configuration is managed elsewhere')
try:
importer.write_config(
text=self.parse_body().get('config'),
modified_by=auth.get_current_identity())
except ValueError as ex:
self.abort_with_error(400, text=str(ex))
self.send_response({'ok': True})
class ImporterIngestTarballHandler(auth.ApiHandler):
"""Accepts PUT with a tarball containing a bunch of groups to import.
The request body is expected to be the tarball as a raw byte stream.
See proto/config.proto, GroupImporterConfig for more details.
"""
# For some reason webapp2 attempts to deserialize the body as a form data when
# searching for XSRF token (which doesn't work when the body is tarball).
# Disable this (along with the cookies-based auth, we want only OAuth2).
xsrf_token_request_param = None
xsrf_token_enforce_on = ()
@classmethod
def get_auth_methods(cls, _conf):
return [auth.oauth_authentication]
# The real authorization check is inside 'ingest_tarball'. This one just
# rejects anonymous calls earlier.
@auth.require(lambda: not auth.get_current_identity().is_anonymous)
def put(self, name):
try:
groups, auth_db_rev = importer.ingest_tarball(name, self.request.body)
self.send_response({
'groups': groups,
'auth_db_rev': auth_db_rev,
})
except importer.BundleImportError as e:
self.abort_with_error(400, error=str(e))
class ServiceListingHandler(auth.ApiHandler):
"""Lists registered replicas with their state."""
@auth.require(acl.has_access)
def get(self):
services = sorted(
replication.AuthReplicaState.query(
ancestor=replication.replicas_root_key()),
key=lambda x: x.key.id())
last_auth_state = model.get_replication_state()
self.send_response({
'services': [
x.to_serializable_dict(with_id_as='app_id') for x in services
],
'auth_code_version': version.__version__,
'auth_db_rev': {
'primary_id': last_auth_state.primary_id,
'rev': last_auth_state.auth_db_rev,
'ts': utils.datetime_to_timestamp(last_auth_state.modified_ts),
},
'now': utils.datetime_to_timestamp(utils.utcnow()),
})
class GenerateLinkingURL(auth.ApiHandler):
"""Generates an URL that can be used to link a new replica.
See auth/proto/replication.proto for the description of the protocol.
"""
@auth.require(auth.is_admin)
def post(self, app_id):
# On local dev server |app_id| may use @localhost:8080 to specify where
# app is running.
custom_host = None
if utils.is_local_dev_server():
app_id, _, custom_host = app_id.partition('@')
# Generate an opaque ticket that would be passed back to /link_replica.
# /link_replica will verify HMAC tag and will ensure the request came from
# application with ID |app_id|.
ticket = LinkTicketToken.generate([], {'app_id': app_id})
# ServiceLinkTicket contains information that is needed for Replica
# to figure out how to contact Primary.
link_msg = replication_pb2.ServiceLinkTicket()
link_msg.primary_id = app_identity.get_application_id()
link_msg.primary_url = self.request.host_url
link_msg.generated_by = auth.get_current_identity().to_bytes()
link_msg.ticket = ticket
# Special case for dev server to simplify local development.
if custom_host:
assert utils.is_local_dev_server()
host = 'http://%s' % custom_host
else:
# Use same domain as auth_service. Usually it's just appspot.com.
current_hostname = app_identity.get_default_version_hostname()
domain = current_hostname.partition('.')[2]
naked_app_id = app_id
if ':' in app_id:
naked_app_id = app_id[app_id.find(':')+1:]
host = 'https://%s.%s' % (naked_app_id, domain)
# URL to a handler on Replica that initiates Replica <-> Primary handshake.
url = '%s/auth/link?t=%s' % (host, b64.encode(link_msg.SerializeToString()))
self.send_response({'url': url}, http_code=201)
class LinkRequestHandler(auth.AuthenticatingHandler):
"""Called by a service that wants to become a Replica."""
# Handler uses X-Appengine-Inbound-Appid header protected by GAE.
xsrf_token_enforce_on = ()
def reply(self, status):
"""Sends serialized ServiceLinkResponse as a response."""
msg = replication_pb2.ServiceLinkResponse()
msg.status = status
self.response.headers['Content-Type'] = 'application/octet-stream'
self.response.write(msg.SerializeToString())
# Check that the request came from some GAE app. It filters out most requests
# from script kiddies right away.
@auth.require(lambda: auth.get_current_identity().is_service)
def post(self):
# Deserialize the body. Dying here with 500 is ok, it should not happen, so
# if it is happening, it's nice to get an exception report.
request = replication_pb2.ServiceLinkRequest.FromString(self.request.body)
# Ensure the ticket was generated by us (by checking HMAC tag).
ticket_data = None
try:
ticket_data = LinkTicketToken.validate(request.ticket, [])
except tokens.InvalidTokenError:
self.reply(replication_pb2.ServiceLinkResponse.BAD_TICKET)
return
# Ensure the ticket was generated for the calling application.
replica_app_id = ticket_data['app_id']
expected_ident = auth.Identity(auth.IDENTITY_SERVICE, replica_app_id)
if auth.get_current_identity() != expected_ident:
self.reply(replication_pb2.ServiceLinkResponse.AUTH_ERROR)
return
# Register the replica. If it is already there, will reset its known state.
replication.register_replica(replica_app_id, request.replica_url)
self.reply(replication_pb2.ServiceLinkResponse.SUCCESS)
################################################################################
## Application routing boilerplate.
def get_routes():
# Use special syntax on dev server to specify where app is running.
app_id_re = r'[0-9a-zA-Z_\-\:\.]*'
if utils.is_local_dev_server():
app_id_re += r'(@localhost:[0-9]+)?'
# Auth service extends the basic UI and API provided by Auth component.
routes = []
routes.extend(rest_api.get_rest_api_routes())
routes.extend(ui.get_ui_routes())
routes.extend([
# UI routes.
webapp2.Route(
r'/', webapp2.RedirectHandler, defaults={'_uri': '/auth/groups'}),
webapp2.Route(r'/_ah/warmup', WarmupHandler),
# API routes.
webapp2.Route(
r'/auth_service/api/v1/authdb/revisions/<rev:(latest|[0-9]+)>',
AuthDBRevisionsHandler),
webapp2.Route(
r'/auth_service/api/v1/authdb/subscription/authorization',
AuthDBSubscriptionAuthHandler),
webapp2.Route(
r'/auth_service/api/v1/importer/config',
ImporterConfigHandler),
webapp2.Route(
r'/auth_service/api/v1/importer/ingest_tarball/<name:.+>',
ImporterIngestTarballHandler),
webapp2.Route(
r'/auth_service/api/v1/internal/link_replica',
LinkRequestHandler),
webapp2.Route(
r'/auth_service/api/v1/services',
ServiceListingHandler),
webapp2.Route(
r'/auth_service/api/v1/services/<app_id:%s>/linking_url' % app_id_re,
GenerateLinkingURL),
])
return routes
def create_application(debug):
replication.configure_as_primary()
rest_api.set_config_locked(config.is_remote_configured)
# Configure UI appearance, add all custom tabs.
ui.configure_ui(
app_name='Auth Service',
ui_tabs=[
ui.GroupsHandler,
ui.ChangeLogHandler,
ui.LookupHandler,
ServicesHandler,
ui.OAuthConfigHandler,
ui.IPAllowlistsHandler,
ConfigHandler,
ui.ApiDocHandler,
],
ui_data_callback=get_additional_ui_data)
template.bootstrap({'auth_service': TEMPLATES_DIR})
# Add a fake admin for local dev server.
if utils.is_local_dev_server():
auth.bootstrap_group(
auth.ADMIN_GROUP,
[auth.Identity(auth.IDENTITY_USER, 'test@example.com')],
'Users that can manage groups')
return webapp2.WSGIApplication(get_routes(), debug=debug)