diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 5a77a64..e6ae3c5 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 +import os from datetime import date, datetime +from functools import wraps from typing import List from dateutil.parser import parse -import httpx -from functools import wraps from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import PyMongoError -import os +from sanic import Sanic +from sanic.response import json, text +from sanic_prometheus import monitor + from .slack import add_slack_routes from .users import users_with_group @@ -15,12 +18,14 @@ app = Sanic(__name__) add_slack_routes(app) monitor(app).expose_endpoint() -DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] # API key for godoor controllers authenticating for k-space:floor -DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] # API key for godoor controllers authenticating for k-space:workshop -FLOOR_ACCESS_GROUP = os.environ["FLOOR_ACCESS_GROUP"] -WORKSHOP_ACCESS_GROUP = os.environ["WORKSHOP_ACCESS_GROUP"] +# API key for godoor controllers authenticating to k-space:floor +DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] +# API key for godoor controllers authenticating to k-space:workshop +DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] +FLOOR_ACCESS_GROUP = os.getenv("FLOOR_ACCESS_GROUP", "k-space:floor") +WORKSHOP_ACCESS_GROUP = os.getenv("WORKSHOP_ACCESS_GROUP", "k-space:workshop") + MONGO_URI = os.environ["MONGO_URI"] -INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # asshole forwards to inventory-app, instead of using mongo directly assert len(DOORBOY_SECRET_FLOOR) >= 10 assert len(DOORBOY_SECRET_WORKSHOP) >= 10 @@ -32,6 +37,7 @@ async def setup_db(app, loop): # https://github.com/sanic-org/sanic/issues/919 app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() + def authenticate_door(wrapped): def decorator(f): @wraps(f) @@ -39,16 +45,19 @@ def authenticate_door(wrapped): doorboy_secret = request.headers.get("KEY") if doorboy_secret not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: return text("Invalid doorboy secret token", status=401) - + return await f(request, *args, **kwargs) + return decorated_function + return decorator(wrapped) + @app.route("/allowed") @authenticate_door async def view_doorboy_uids(request): users = List[str] - + # authorize key = request.headers.get("KEY") if key == DOORBOY_SECRET_FLOOR: @@ -58,59 +67,70 @@ async def view_doorboy_uids(request): else: print("WARN: unknown door token in /allowed") return "unknown doorboy secret token", 403 - + flt = { "token.uid_hash": {"$exists": True}, - "inventory.owner.username": {"$in": users} - } - prj = { - "token.uid_hash": True + "inventory.owner.username": {"$in": users}, } + prj = {"token.uid_hash": True} tokens = await app.ctx.db.inventory.find(flt, prj) - + print(f"delegating {len(tokens)} doorkey tokens") return json({"allowed_uids": tokens}) + def datetime_to_json_formatting(o): if isinstance(o, (date, datetime)): return o.isoformat() + # Only identified and successful events. **Endpoint not in use.** @app.route("/logs") async def view_open_door_events(request): return text("not an admin"), 403 - results = await app.ctx.db.eventlog.find({ - "component": "doorboy", - "type": "open-door", - "approved": True, - "$or": [ - { "type": "open-door" }, - { "event": "card-swiped" }, - ], - "door": { "$exists": True }, - "timestamp": { "$exists": True } - }).sort("timestamp", -1).to_list(length=None) + results = ( + await app.ctx.db.eventlog.find( + { + "component": "doorboy", + "type": "open-door", + "approved": True, + "$or": [ + {"type": "open-door"}, + {"event": "card-swiped"}, + ], + "door": {"$exists": True}, + "timestamp": {"$exists": True}, + } + ) + .sort("timestamp", -1) + .to_list(length=None) + ) transformed = [] for r in results: if r.get("type") == "open-door" and r.get("approved") and r.get("method"): - transformed.append({ - "method": r.get("method"), - "door": r["door"], - "timestamp": r.get("timestamp"), - "member": r.get("member"), - }) + transformed.append( + { + "method": r.get("method"), + "door": r["door"], + "timestamp": r.get("timestamp"), + "member": r.get("member"), + } + ) if r.get("event") == "card-swiped" and r.get("success"): - transformed.append({ - "method": "card-swiped", - "door": r["door"], - "timestamp": r.get("timestamp"), - "member": r.get("inventory", {}).get("owner") - }) + transformed.append( + { + "method": "card-swiped", + "door": r["door"], + "timestamp": r.get("timestamp"), + "member": r.get("inventory", {}).get("owner"), + } + ) return json(transformed, default=datetime_to_json_formatting) + @app.route("/longpoll", stream=True) @authenticate_door async def view_longpoll(request): @@ -119,8 +139,8 @@ async def view_longpoll(request): pipeline = [ { "$match": { - "operationType": "insert", - } + "operationType": "insert", + } } ] try: @@ -132,13 +152,14 @@ async def view_longpoll(request): continue if ev["type"] == "token": continue - + response.send("data: %s\n\n" % ev["door"]) except PyMongoError as e: print(e) await response.send("data: response-generator-ended\n\n") return + # Called by the door to log a card swipe. Does not decide whether the door should be opened. @app.post("/swipe") @authenticate_door @@ -155,31 +176,31 @@ async def swipe(request): print("WARN: Door", repr(data.get("door")), "not in", doors) return text("Not allowed", 403) - timestamp = parse(data["timestamp"]) if data.get("timestamp") else datetime.now(datetime.timezone.utc) - + timestamp = ( + parse(data["timestamp"]) + if data.get("timestamp") + else datetime.now(datetime.timezone.utc) + ) + # Update token, create if unknown - app.ctx.db.inventory.update_one({ - "component": "doorboy", - "type": "token", - "token.uid_hash": data["uid_hash"] - }, { - "$set": { - "last_seen": timestamp + await app.ctx.db.inventory.update_one( + {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]}, + { + "$set": {"last_seen": timestamp}, + "$setOnInsert": { + "first_seen": timestamp, + "inventory": { + "claimable": True, + }, + }, }, - "$setOnInsert": { - "first_seen": timestamp, - "inventory": { - "claimable": True, - } - } - }, upsert=True) - - token = app.ctx.db.inventory.find_one({ - "component": "doorboy", - "type": "token", - "token.uid_hash": data["uid_hash"] - }) - + upsert=True, + ) + + token = await app.ctx.db.inventory.find_one( + {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]} + ) + event_swipe = { "component": "doorboy", "method": "token", @@ -187,15 +208,20 @@ async def swipe(request): "door": data["door"], "event": "card-swiped", "approved": data["approved"], - "uid_hash": data["uid_hash"], + "uid_hash": data["uid_hash"], "user": { "id": token.get("inventory", {}).get("owner", {}).get("username", ""), - "name": token.get("inventory", {}).get("owner", {}).get("display_name", "Unclaimed Token") - } + "name": token.get("inventory", {}) + .get("owner", {}) + .get("display_name", "Unclaimed Token"), + }, } - app.ctx.db.eventlog.insert_one(event_swipe) + await app.ctx.db.eventlog.insert_one(event_swipe) return text("ok") + if __name__ == "__main__": - app.run(debug=False, host="0.0.0.0", port=5000, single_process=True, access_log=True) + app.run( + debug=False, host="0.0.0.0", port=5000, single_process=True, access_log=True + ) diff --git a/app/slack.py b/app/slack.py index 51cc083..c34eea3 100644 --- a/app/slack.py +++ b/app/slack.py @@ -3,10 +3,13 @@ from requests.exceptions import RequestException import os import requests -SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] # webhook logs to private channel or "DEV" to print to console. +# webhook logs to private channel or "DEV" to print to console. +SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] + def add_slack_routes(app): - app.app.register_listener(slack_log, "after_server_start") # consumes SLACK_DOORLOG_CALLBACK and app.ctx.db + app.app.register_listener(slack_log, "after_server_start") + def slack_post(msg): if SLACK_DOORLOG_CALLBACK == "DEV": @@ -14,30 +17,40 @@ def slack_post(msg): return try: - requests.post(SLACK_DOORLOG_CALLBACK, json={"text": msg }).raise_for_status() + requests.post(SLACK_DOORLOG_CALLBACK, json={"text": msg}).raise_for_status() except RequestException as e: print(f"[SLACK]: {e}") + def approvedStr(approved: bool) -> str: if approved: return "Permitted" - + return "Denied" + +# consumes SLACK_DOORLOG_CALLBACK and app.ctx.db async def slack_log(app, loop): - pipeline = [{ - "$match": { - "operationType": "insert", + pipeline = [ + { + "$match": { + "operationType": "insert", + } } - }] + ] while True: try: async with app.ctx.db.eventlog.watch(pipeline) as stream: async for event in stream: ev = event["fullDocument"] - - msg = "%s %s access for %s via %s" % (approvedStr(ev["approved"]), ev["door"], ev["user"]["name"], ev("method")) + + msg = "%s %s access for %s via %s" % ( + approvedStr(ev["approved"]), + ev["door"], + ev["user"]["name"], + ev("method"), + ) slack_post(msg) - + except PyMongoError as e: print(e) diff --git a/app/users.py b/app/users.py index ef93c3e..0752407 100644 --- a/app/users.py +++ b/app/users.py @@ -1,22 +1,24 @@ - from typing import List from kubernetes import client, config import os OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") + def users_with_group(group: str) -> List[str]: config.load_incluster_config() api_instance = client.CustomObjectsApi() - + users = List[str] - - ret = api_instance.list_namespaced_custom_object("codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers") + + ret = api_instance.list_namespaced_custom_object( + "codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers" + ) for item in ret["items"]: if group not in item.get("status", {}).get("groups", []): continue - - users.append(item['metadata']['name']) - + + users.append(item["metadata"]["name"]) + print(f"INFO: {len(users)} users in group {group}") return users diff --git a/docker-compose.yml b/docker-compose.yml index 753affc..d847d7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: mongoexpress: image: mongo-express diff --git a/requirements.txt b/requirements.txt index 1b86442..bf637e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,7 @@ -httpx==0.28.1 motor==3.7.1 pymongo==4.14.0 python_dateutil==2.9.0 Requests==2.32.4 sanic==25.3.0 sanic_prometheus==0.2.1 -kubernetes +kubernetes==33.1.0