#!/usr/bin/env python3 # Copyright (C) 2018-2019 Lars Wirzenius # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import copy import json import logging import os import sys import bottle import muck class MuckAPI: def __init__(self, bottleapp, store, signing_key_text): self._add_routes(bottleapp) self._store = store self._gen = muck.IdGenerator() self._ac = muck.AuthorizationChecker(signing_key_text) def _add_routes(self, bottleapp): routes = [ { 'method': 'POST', 'path': '/res', 'callback': self._check_authz( 'POST', 'create', self._create_res), }, { 'method': 'PUT', 'path': '/res', 'callback': self._check_authz( 'PUT', 'update', self._update_res), }, { 'method': 'GET', 'path': '/res', 'callback': self._check_authz( 'GET', 'show', self._show_res), }, { 'method': 'DELETE', 'path': '/res', 'callback': self._check_authz( 'DELETE', 'delete', self._delete_res), }, { 'method': 'GET', 'path': '/search', 'callback': self._check_authz( 'GET', 'show', self._search_res), }, { 'method': 'GET', 'path': '/status', 'callback': self._show_status, }, ] for route in routes: bottleapp.route(**route) def _check_authz(self, req_method, req_scope, callback): def check_authz(): try: rr = bottle.request logging.info('Request: %s %s', rr.method, rr.path) logging.debug('Request headers:') for h in rr.headers: logging.debug(' %s: %r', h, rr.headers[h]) logging.debug('End of request headers') r = muck.Request(method=rr.method) r.add_headers(rr.headers) if self._ac.request_is_allowed(r, req_method, [req_scope]): claims = self._ac.get_claims_from_token(r) claims = self._claims_as_effective_user(r, claims) logging.info('Claims (with effective user): %r', claims) return callback(claims) logging.error('Access denied') return bottle.HTTPError(401) except BaseException as e: logging.error(repr(e), exc_info=True) raise return check_authz def _create_res(self, claims): res = self._get_json_body() meta = { 'id': self._gen.new_id(), 'rev': self._gen.new_id(), 'owner': claims.get('sub'), } create = muck.CreateChange(meta, res) self._store.change(create) return self._create_response(201, 'create', meta, res) def _update_res(self, claims): rid = self._get_resource_id() try: meta, _ = self._get_existing(rid) except bottle.HTTPError as e: return e if not self._access_is_allowed(meta, claims): return bottle.HTTPError(status=404) rev = self._get_resource_revision() if meta['rev'] != rev: return bottle.HTTPError(status=400, body='Wrong revision') res = self._get_json_body() meta['rev'] = self._gen.new_id() update = muck.UpdateChange(meta, res) self._store.change(update) return self._create_response(200, 'change', meta, res) def _show_res(self, claims): rid = self._get_resource_id() try: meta, res = self._get_existing(rid) except bottle.HTTPError as e: return e if not self._access_is_allowed(meta, claims): return bottle.HTTPError(status=404) return self._create_response(200, 'show', meta, res) def _delete_res(self, claims): rid = self._get_resource_id() try: meta, res = self._get_existing(rid) except bottle.HTTPError as e: return e if not self._access_is_allowed(meta, claims): return bottle.HTTPError(status=404) delete = muck.DeleteChange(meta, res) self._store.change(delete) return self._create_response(200, 'delete', meta, res) def _search_res(self, claims): def is_showable(rid): try: meta, res = self._get_existing(rid) except bottle.HTTPError as e: return False return self._access_is_allowed(meta, claims) body = self._get_json_body() cond = body.get('cond') ms = self._store.get_memory_store() hits = [ rid for rid in ms.search(cond) if is_showable(rid) ] result = { 'resources': hits, } return bottle.HTTPResponse(status=200, body=json.dumps(result)) def _show_status(self): ms = self._store.get_memory_store() status = { 'resources': len(ms) } return bottle.HTTPResponse(status=200, body=json.dumps(status)) def _get_json_body(self): f = bottle.request.body body = f.read() if isinstance(body, bytes): body = body.decode('UTF-8') return json.loads(body, encoding='UTF-8') def _get_resource_id(self): return bottle.request.headers.get('Muck-Id') def _get_resource_revision(self): return bottle.request.headers.get('Muck-Revision') def _get_existing(self, rid): ms = self._store.get_memory_store() if rid not in ms: logging.warning('{}: not found'.format(rid)) raise bottle.HTTPResponse(status=404) return ms[rid] def _access_is_allowed(self, meta, claims): scopes = claims.get('scope', '').split() return claims['sub'] == meta['owner'] or 'super' in scopes def _create_response(self, status, operation, meta, res): headers = self._meta_headers(meta) return bottle.HTTPResponse( status=status, headers=headers, body=json.dumps(res)) def _meta_headers(self, meta): return { 'Muck-ID': meta['id'], 'Muck-Revision': meta['rev'], 'Muck-Owner': meta['owner'], } def _claims_as_effective_user(self, r, claims): scopes = claims.get('scope', '').split() if 'super' in scopes: claims = copy.deepcopy(claims) user = r.get_user() if user: claims['sub'] = user logging.info( 'Pretending to be %s (claims: %r)', claims['sub'], claims) else: logging.info('Request by normal user') return claims with open(sys.argv[1]) as f: config = json.load(f) logging.basicConfig( filename=config['log'], level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') logging.info('Muck starts') signing_key_text = open(config['signing-key-filename']).read() store = muck.Store(config['store']) if config.get('pid'): pid = os.getpid() with open(config['pid'], 'w') as f: f.write(str(pid)) port = config.get('port', 12765) app = bottle.default_app() api = MuckAPI(app, store, signing_key_text) bottle.run(host='127.0.0.1', port=port)