fmt with ruff
This commit is contained in:
		| @@ -1,13 +1,16 @@ | |||||||
| #!/usr/bin/env python3 | #!/usr/bin/env python3 | ||||||
|  | import os | ||||||
| from datetime import date, datetime | from datetime import date, datetime | ||||||
|  | from functools import wraps | ||||||
| from typing import List | from typing import List | ||||||
|  |  | ||||||
| from dateutil.parser import parse | from dateutil.parser import parse | ||||||
| import httpx |  | ||||||
| from functools import wraps |  | ||||||
| from motor.motor_asyncio import AsyncIOMotorClient | from motor.motor_asyncio import AsyncIOMotorClient | ||||||
| from pymongo.errors import PyMongoError | 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 .slack import add_slack_routes | ||||||
| from .users import users_with_group | from .users import users_with_group | ||||||
|  |  | ||||||
| @@ -15,12 +18,14 @@ app = Sanic(__name__) | |||||||
| add_slack_routes(app) | add_slack_routes(app) | ||||||
| monitor(app).expose_endpoint() | monitor(app).expose_endpoint() | ||||||
|  |  | ||||||
| DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] # API key for godoor controllers authenticating for k-space:floor | # API key for godoor controllers authenticating to k-space:floor | ||||||
| DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] # API key for godoor controllers authenticating for k-space:workshop | DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] | ||||||
| FLOOR_ACCESS_GROUP = os.environ["FLOOR_ACCESS_GROUP"] | # API key for godoor controllers authenticating to k-space:workshop | ||||||
| WORKSHOP_ACCESS_GROUP = os.environ["WORKSHOP_ACCESS_GROUP"] | 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"] | 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_FLOOR) >= 10 | ||||||
| assert len(DOORBOY_SECRET_WORKSHOP) >= 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 |     # https://github.com/sanic-org/sanic/issues/919 | ||||||
|     app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() |     app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() | ||||||
|  |  | ||||||
|  |  | ||||||
| def authenticate_door(wrapped): | def authenticate_door(wrapped): | ||||||
|     def decorator(f): |     def decorator(f): | ||||||
|         @wraps(f) |         @wraps(f) | ||||||
| @@ -39,16 +45,19 @@ def authenticate_door(wrapped): | |||||||
|             doorboy_secret = request.headers.get("KEY") |             doorboy_secret = request.headers.get("KEY") | ||||||
|             if doorboy_secret not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: |             if doorboy_secret not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: | ||||||
|                 return text("Invalid doorboy secret token", status=401) |                 return text("Invalid doorboy secret token", status=401) | ||||||
|              |  | ||||||
|             return await f(request, *args, **kwargs) |             return await f(request, *args, **kwargs) | ||||||
|  |  | ||||||
|         return decorated_function |         return decorated_function | ||||||
|  |  | ||||||
|     return decorator(wrapped) |     return decorator(wrapped) | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.route("/allowed") | @app.route("/allowed") | ||||||
| @authenticate_door | @authenticate_door | ||||||
| async def view_doorboy_uids(request): | async def view_doorboy_uids(request): | ||||||
|     users = List[str] |     users = List[str] | ||||||
|      |  | ||||||
|     # authorize |     # authorize | ||||||
|     key = request.headers.get("KEY") |     key = request.headers.get("KEY") | ||||||
|     if key == DOORBOY_SECRET_FLOOR: |     if key == DOORBOY_SECRET_FLOOR: | ||||||
| @@ -58,59 +67,70 @@ async def view_doorboy_uids(request): | |||||||
|     else: |     else: | ||||||
|         print("WARN: unknown door token in /allowed") |         print("WARN: unknown door token in /allowed") | ||||||
|         return "unknown doorboy secret token", 403 |         return "unknown doorboy secret token", 403 | ||||||
|      |  | ||||||
|     flt = { |     flt = { | ||||||
|         "token.uid_hash": {"$exists": True}, |         "token.uid_hash": {"$exists": True}, | ||||||
|         "inventory.owner.username": {"$in": users} |         "inventory.owner.username": {"$in": users}, | ||||||
|     } |  | ||||||
|     prj = { |  | ||||||
|         "token.uid_hash": True |  | ||||||
|     } |     } | ||||||
|  |     prj = {"token.uid_hash": True} | ||||||
|     tokens = await app.ctx.db.inventory.find(flt, prj) |     tokens = await app.ctx.db.inventory.find(flt, prj) | ||||||
|      |  | ||||||
|     print(f"delegating {len(tokens)} doorkey tokens") |     print(f"delegating {len(tokens)} doorkey tokens") | ||||||
|     return json({"allowed_uids": tokens}) |     return json({"allowed_uids": tokens}) | ||||||
|  |  | ||||||
|  |  | ||||||
| def datetime_to_json_formatting(o): | def datetime_to_json_formatting(o): | ||||||
|     if isinstance(o, (date, datetime)): |     if isinstance(o, (date, datetime)): | ||||||
|         return o.isoformat() |         return o.isoformat() | ||||||
|  |  | ||||||
|  |  | ||||||
| # Only identified and successful events. **Endpoint not in use.** | # Only identified and successful events. **Endpoint not in use.** | ||||||
| @app.route("/logs") | @app.route("/logs") | ||||||
| async def view_open_door_events(request): | async def view_open_door_events(request): | ||||||
|     return text("not an admin"), 403 |     return text("not an admin"), 403 | ||||||
|  |  | ||||||
|     results = await app.ctx.db.eventlog.find({ |     results = ( | ||||||
|         "component": "doorboy", |         await app.ctx.db.eventlog.find( | ||||||
|         "type": "open-door", |             { | ||||||
|         "approved": True, |                 "component": "doorboy", | ||||||
|         "$or": [ |                 "type": "open-door", | ||||||
|             { "type": "open-door" }, |                 "approved": True, | ||||||
|             { "event": "card-swiped" }, |                 "$or": [ | ||||||
|         ], |                     {"type": "open-door"}, | ||||||
|         "door": { "$exists": True }, |                     {"event": "card-swiped"}, | ||||||
|         "timestamp": { "$exists": True } |                 ], | ||||||
|     }).sort("timestamp", -1).to_list(length=None) |                 "door": {"$exists": True}, | ||||||
|  |                 "timestamp": {"$exists": True}, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |         .sort("timestamp", -1) | ||||||
|  |         .to_list(length=None) | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     transformed = [] |     transformed = [] | ||||||
|     for r in results: |     for r in results: | ||||||
|         if r.get("type") == "open-door" and r.get("approved") and r.get("method"): |         if r.get("type") == "open-door" and r.get("approved") and r.get("method"): | ||||||
|             transformed.append({ |             transformed.append( | ||||||
|                 "method": r.get("method"), |                 { | ||||||
|                 "door": r["door"], |                     "method": r.get("method"), | ||||||
|                 "timestamp": r.get("timestamp"), |                     "door": r["door"], | ||||||
|                 "member": r.get("member"), |                     "timestamp": r.get("timestamp"), | ||||||
|             }) |                     "member": r.get("member"), | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|         if r.get("event") == "card-swiped" and r.get("success"): |         if r.get("event") == "card-swiped" and r.get("success"): | ||||||
|             transformed.append({ |             transformed.append( | ||||||
|                 "method": "card-swiped", |                 { | ||||||
|                 "door": r["door"], |                     "method": "card-swiped", | ||||||
|                 "timestamp": r.get("timestamp"), |                     "door": r["door"], | ||||||
|                 "member": r.get("inventory", {}).get("owner") |                     "timestamp": r.get("timestamp"), | ||||||
|             }) |                     "member": r.get("inventory", {}).get("owner"), | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |  | ||||||
|     return json(transformed, default=datetime_to_json_formatting) |     return json(transformed, default=datetime_to_json_formatting) | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.route("/longpoll", stream=True) | @app.route("/longpoll", stream=True) | ||||||
| @authenticate_door | @authenticate_door | ||||||
| async def view_longpoll(request): | async def view_longpoll(request): | ||||||
| @@ -119,8 +139,8 @@ async def view_longpoll(request): | |||||||
|     pipeline = [ |     pipeline = [ | ||||||
|         { |         { | ||||||
|             "$match": { |             "$match": { | ||||||
|                  "operationType": "insert", |                 "operationType": "insert", | ||||||
|              } |             } | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|     try: |     try: | ||||||
| @@ -132,13 +152,14 @@ async def view_longpoll(request): | |||||||
|                     continue |                     continue | ||||||
|                 if ev["type"] == "token": |                 if ev["type"] == "token": | ||||||
|                     continue |                     continue | ||||||
|                  |  | ||||||
|                 response.send("data: %s\n\n" % ev["door"]) |                 response.send("data: %s\n\n" % ev["door"]) | ||||||
|     except PyMongoError as e: |     except PyMongoError as e: | ||||||
|         print(e) |         print(e) | ||||||
|         await response.send("data: response-generator-ended\n\n") |         await response.send("data: response-generator-ended\n\n") | ||||||
|         return |         return | ||||||
|  |  | ||||||
|  |  | ||||||
| # Called by the door to log a card swipe. Does not decide whether the door should be opened. | # Called by the door to log a card swipe. Does not decide whether the door should be opened. | ||||||
| @app.post("/swipe") | @app.post("/swipe") | ||||||
| @authenticate_door | @authenticate_door | ||||||
| @@ -155,31 +176,31 @@ async def swipe(request): | |||||||
|         print("WARN: Door", repr(data.get("door")), "not in", doors) |         print("WARN: Door", repr(data.get("door")), "not in", doors) | ||||||
|         return text("Not allowed", 403) |         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 |     # Update token, create if unknown | ||||||
|     app.ctx.db.inventory.update_one({ |     await app.ctx.db.inventory.update_one( | ||||||
|         "component": "doorboy", |         {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]}, | ||||||
|         "type": "token", |         { | ||||||
|         "token.uid_hash": data["uid_hash"] |             "$set": {"last_seen": timestamp}, | ||||||
|     }, { |             "$setOnInsert": { | ||||||
|         "$set": { |                 "first_seen": timestamp, | ||||||
|             "last_seen": timestamp |                 "inventory": { | ||||||
|  |                     "claimable": True, | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|         }, |         }, | ||||||
|         "$setOnInsert": { |         upsert=True, | ||||||
|             "first_seen": timestamp, |     ) | ||||||
|             "inventory": { |  | ||||||
|                 "claimable": True, |     token = await app.ctx.db.inventory.find_one( | ||||||
|             } |         {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]} | ||||||
|         } |     ) | ||||||
|     }, upsert=True) |  | ||||||
|      |  | ||||||
|     token = app.ctx.db.inventory.find_one({ |  | ||||||
|         "component": "doorboy", |  | ||||||
|         "type": "token", |  | ||||||
|         "token.uid_hash": data["uid_hash"] |  | ||||||
|     }) |  | ||||||
|      |  | ||||||
|     event_swipe = { |     event_swipe = { | ||||||
|         "component": "doorboy", |         "component": "doorboy", | ||||||
|         "method": "token", |         "method": "token", | ||||||
| @@ -187,15 +208,20 @@ async def swipe(request): | |||||||
|         "door": data["door"], |         "door": data["door"], | ||||||
|         "event": "card-swiped", |         "event": "card-swiped", | ||||||
|         "approved": data["approved"], |         "approved": data["approved"], | ||||||
|         "uid_hash":  data["uid_hash"], |         "uid_hash": data["uid_hash"], | ||||||
|         "user": { |         "user": { | ||||||
|             "id": token.get("inventory", {}).get("owner", {}).get("username", ""), |             "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") |     return text("ok") | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | 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 | ||||||
|  |     ) | ||||||
|   | |||||||
							
								
								
									
										35
									
								
								app/slack.py
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								app/slack.py
									
									
									
									
									
								
							| @@ -3,10 +3,13 @@ from requests.exceptions import RequestException | |||||||
| import os | import os | ||||||
| import requests | 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): | 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): | def slack_post(msg): | ||||||
|     if SLACK_DOORLOG_CALLBACK == "DEV": |     if SLACK_DOORLOG_CALLBACK == "DEV": | ||||||
| @@ -14,30 +17,40 @@ def slack_post(msg): | |||||||
|         return |         return | ||||||
|  |  | ||||||
|     try: |     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: |     except RequestException as e: | ||||||
|         print(f"[SLACK]: {e}") |         print(f"[SLACK]: {e}") | ||||||
|  |  | ||||||
|  |  | ||||||
| def approvedStr(approved: bool) -> str: | def approvedStr(approved: bool) -> str: | ||||||
|     if approved: |     if approved: | ||||||
|         return "Permitted" |         return "Permitted" | ||||||
|      |  | ||||||
|     return "Denied" |     return "Denied" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # consumes SLACK_DOORLOG_CALLBACK and app.ctx.db | ||||||
| async def slack_log(app, loop): | async def slack_log(app, loop): | ||||||
|     pipeline = [{ |     pipeline = [ | ||||||
|         "$match": { |         { | ||||||
|             "operationType": "insert", |             "$match": { | ||||||
|  |                 "operationType": "insert", | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     }] |     ] | ||||||
|     while True: |     while True: | ||||||
|         try: |         try: | ||||||
|             async with app.ctx.db.eventlog.watch(pipeline) as stream: |             async with app.ctx.db.eventlog.watch(pipeline) as stream: | ||||||
|                 async for event in stream: |                 async for event in stream: | ||||||
|                     ev = event["fullDocument"] |                     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) |                     slack_post(msg) | ||||||
|          |  | ||||||
|         except PyMongoError as e: |         except PyMongoError as e: | ||||||
|             print(e) |             print(e) | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								app/users.py
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								app/users.py
									
									
									
									
									
								
							| @@ -1,22 +1,24 @@ | |||||||
|  |  | ||||||
| from typing import List | from typing import List | ||||||
| from kubernetes import client, config | from kubernetes import client, config | ||||||
| import os | import os | ||||||
|  |  | ||||||
| OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") | OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") | ||||||
|  |  | ||||||
|  |  | ||||||
| def users_with_group(group: str) -> List[str]: | def users_with_group(group: str) -> List[str]: | ||||||
|     config.load_incluster_config() |     config.load_incluster_config() | ||||||
|     api_instance = client.CustomObjectsApi() |     api_instance = client.CustomObjectsApi() | ||||||
|      |  | ||||||
|     users = List[str] |     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"]: |     for item in ret["items"]: | ||||||
|         if group not in item.get("status", {}).get("groups", []): |         if group not in item.get("status", {}).get("groups", []): | ||||||
|             continue |             continue | ||||||
|          |  | ||||||
|         users.append(item['metadata']['name']) |         users.append(item["metadata"]["name"]) | ||||||
|      |  | ||||||
|     print(f"INFO: {len(users)} users in group {group}") |     print(f"INFO: {len(users)} users in group {group}") | ||||||
|     return users |     return users | ||||||
|   | |||||||
| @@ -1,5 +1,3 @@ | |||||||
| version: '3.7' |  | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   mongoexpress: |   mongoexpress: | ||||||
|     image: mongo-express |     image: mongo-express | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| httpx==0.28.1 |  | ||||||
| motor==3.7.1 | motor==3.7.1 | ||||||
| pymongo==4.14.0 | pymongo==4.14.0 | ||||||
| python_dateutil==2.9.0 | python_dateutil==2.9.0 | ||||||
| Requests==2.32.4 | Requests==2.32.4 | ||||||
| sanic==25.3.0 | sanic==25.3.0 | ||||||
| sanic_prometheus==0.2.1 | sanic_prometheus==0.2.1 | ||||||
| kubernetes | kubernetes==33.1.0 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user