Blair Conrad

App Engine + External Authentication: Exposing Handlers to Cron, Tasks, and Admins

Since Google is deprecating OpenID 2.0 support, I decided to update LibraryHippo to authenticate via OAuth 2.0, which is a story in itself, but I'm here to talk about what happened next.

LibraryHippo has a set of handlers that are accessed primarily via the Cron and Task Queue mechanisms, but every once in a while need to be triggered ad hoc by a human administrator. Up 'til now, these request handlers were protected from the rabble by requiring administrator status via the application's app.yaml. Unfortunately, externally-authenticated users have no special standing within App Engine, so this restriction had to be relaxed.

My first thought was to remove the restriction from app.yaml and check for access in the handler like so:

if (users.is_current_user_admin() or
    self.is_external_user_admin()) # application code that understands the logged-in users
    # do stuff
else:
    self.abort(403)

Unfortunately, this fails miserably. When the handler is executed by a task or cron job, users.is_current_user_admin returns False.

This behaviour seems not to be widely reported; I couldn't find it mentioned in the App Engine issues list, but a web search eventually turned up App Engine: Google fails users.is_current_user_admin() test by Ben Davies, an article written nearly 5 years ago.

In this article, Mr. Davies suggests that the best alternative to users.is_current_user_admin is to "check the easily spoofed request user-agent". I was skittish of this approach, especially since Google is now recommending checking X-AppEngine-Cron when securing URLS for cron. The App Engine documentation explains how X-AppEngine-Cron is protected against spoofing, but I'm still uneasy.

I ended up taking a different approach. I added two routes for the affected handlers. One route is in the old "admin" subdirectory (subpath?) and the other in a new one for system commands, "system". The latter is secured in the app.yaml, just as before. Thus I have:

# in app.yaml
- url: /system/.*
  script: libraryhippo.application
  login: admin
# in the application's Python source
handlers = [
    # other handlers
    ('/admin/notify/(.*)$', Notify),
    ('/system/notify/(.*)$', Notify),
    ]

# and later, in the Notify handler
    request_path = urlparse.urlsplit(self.request.url).path
    if (self.is_external_user_admin() or
        not request_path.startswith('/admin/'))
        # do stuff
    else:
        self.abort(403)

Thus the handler is executed if the user has admin rights or the URL isn't locked down by virtue of being below '/admin/'. The '/system/' URLs are all assumed to be protected by the app.yaml setting.

Perhaps this is technically no better than checking a header in the request, but it works for me, at least until I see what happens with Issue 11576: have users.is_current_user_admin return true for tasks and cron jobs.