From 829da1f55fd8f340b76e27470c62309ba8dec598 Mon Sep 17 00:00:00 2001 From: rasmus Date: Thu, 7 Aug 2025 20:43:39 +0300 Subject: [PATCH 01/12] disable /open-door-events not in use anywhere rename to /logs --- app/doorboy-proxy.py | 8 +++----- docker-compose.yml | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 4767300..d13c274 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -14,7 +14,6 @@ monitor(app).expose_endpoint() INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] -DOORBOY_SECRET_OPEN_EVENTS = os.environ["DOORBOY_SECRET_OPEN_EVENTS"] CARD_URI = os.environ["CARD_URI"] FLOOR_ACCESS_GROUP = os.environ["FLOOR_ACCESS_GROUP"] WORKSHOP_ACCESS_GROUP = os.environ["WORKSHOP_ACCESS_GROUP"] @@ -63,11 +62,10 @@ def datetime_to_json_formatting(o): if isinstance(o, (date, datetime)): return o.isoformat() -@app.route("/open-door-events") +# Only identified and successful events. **Endpoint not in use.** +@app.route("/logs") async def view_open_door_events(request): - key = request.headers.get("KEY") - if not key or key != DOORBOY_SECRET_OPEN_EVENTS: - return text("Invalid token") + return text("not an admin"), 403 results = await app.ctx.db.eventlog.find({ "component": "doorboy", diff --git a/docker-compose.yml b/docker-compose.yml index de6318c..c216584 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,6 @@ services: INVENTORY_API_KEY: "sptWL6XFxl4b8" DOORBOY_SECRET_FLOOR: "0123456789" DOORBOY_SECRET_WORKSHOP: "9999999999" - DOORBOY_SECRET_OPEN_EVENTS: "1111111111" FLOOR_ACCESS_GROUP: "k-space:floor" WORKSHOP_ACCESS_GROUP: "k-space:workshop" CARD_URI: "https://inventory-app-72zn4.codemowers.ee/cards" From 55ca2359467cdb7a005455667ae0b89d368311da Mon Sep 17 00:00:00 2001 From: rasmus Date: Thu, 7 Aug 2025 20:44:23 +0300 Subject: [PATCH 02/12] tiny refactor /longpoltiny and /longpolll --- app/doorboy-proxy.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index d13c274..91b6de6 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -11,13 +11,13 @@ import os app = Sanic(__name__) monitor(app).expose_endpoint() -INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] -DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_FLOOR"] -DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] -CARD_URI = os.environ["CARD_URI"] +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"] MONGO_URI = os.environ["MONGO_URI"] +INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # asshole forwards to inventory-app, instead of using mongo directly +CARD_URI = os.environ["CARD_URI"] SWIPE_URI = os.environ["SWIPE_URI"] assert len(DOORBOY_SECRET_FLOOR) >= 10 @@ -70,10 +70,7 @@ async def view_open_door_events(request): results = await app.ctx.db.eventlog.find({ "component": "doorboy", "type": "open-door", - "$or": [ - { "approved": True }, - { "success": True }, - ], + "approved": True, "$or": [ { "type": "open-door" }, { "event": "card-swiped" }, @@ -106,7 +103,8 @@ async def view_longpoll(request): key = request.headers.get("KEY") if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: return text("Invalid token") - + + # authenticate response = await request.respond(content_type="text/event-stream") await response.send("data: response-generator-started\n\n") pipeline = [ @@ -120,19 +118,26 @@ async def view_longpoll(request): async with app.ctx.db.eventlog.watch(pipeline) as stream: await response.send("data: watch-stream-opened\n\n") async for event in stream: - if event["fullDocument"].get("type") == "open-door" and event["fullDocument"].get("approved", False): - await response.send("data: %s\n\n" % - event["fullDocument"]["door"]) - except pymongo.errors.PyMongoError as e: + ev = event["fullDocument"] + if ev["approved"] != "true": + 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 @app.post("/swipe") async def forward_swipe(request): + # authenticate key = request.headers.get("KEY") if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: return text("Invalid token", status=401) + + # authorize data = request.json doors = set() if key == DOORBOY_SECRET_FLOOR: From 988b7e964ec594039f0b57a213555f08e6c19c48 Mon Sep 17 00:00:00 2001 From: rasmus Date: Thu, 7 Aug 2025 23:18:45 +0300 Subject: [PATCH 03/12] refactor slack logger --- .gitignore | 1 + app/doorboy-proxy.py | 49 ++++++++++++++++++++++++++++++++++---------- app/slack.py | 43 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 3 ++- requirements.txt | 7 +++++++ 5 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 app/slack.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 8c75193..b00221b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env .overnodebundle +.venv diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 91b6de6..69969a2 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -3,12 +3,14 @@ from datetime import date, datetime from sanic import Sanic from sanic.response import text, json from sanic_prometheus import monitor -from motor.motor_asyncio import AsyncIOMotorClient import httpx -import pymongo +from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.errors import PyMongoError import os +from .slack import add_slack_routes 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 @@ -148,16 +150,41 @@ async def forward_swipe(request): print("Door", repr(data.get("door")), "not in", doors) return text("Not allowed", 403) - async with httpx.AsyncClient() as client: - r = await client.post(SWIPE_URI, json=data, headers={ - "Content-Type": "application/json", - "Authorization": f"Basic {INVENTORY_API_KEY}" - }) - if r.status_code == 200: - return text("ok") - else: - return text("Failed", 500) +def slack_post(msg): + if SLACK_DOORLOG_CALLBACK == "DEV": + print(f"[DEV SLACK]: {msg}") + return + try: + requests.post(SLACK_DOORLOG_CALLBACK, json={"text": msg }).raise_for_status() + except requests.exceptions.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 +@app.listener("after_server_start") +async def slack_log(app, loop): + 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")) + slack_post(msg) + + except PyMongoError as e: + print(e) if __name__ == "__main__": 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 new file mode 100644 index 0000000..51cc083 --- /dev/null +++ b/app/slack.py @@ -0,0 +1,43 @@ +from pymongo.errors import PyMongoError +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. + +def add_slack_routes(app): + app.app.register_listener(slack_log, "after_server_start") # consumes SLACK_DOORLOG_CALLBACK and app.ctx.db + +def slack_post(msg): + if SLACK_DOORLOG_CALLBACK == "DEV": + print(f"[DEV SLACK]: {msg}") + return + + try: + 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" + +async def slack_log(app, loop): + 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")) + slack_post(msg) + + except PyMongoError as e: + print(e) diff --git a/docker-compose.yml b/docker-compose.yml index c216584..9efa962 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: FLOOR_ACCESS_GROUP: "k-space:floor" WORKSHOP_ACCESS_GROUP: "k-space:workshop" CARD_URI: "https://inventory-app-72zn4.codemowers.ee/cards" - SWIPE_URI: "https://inventory-app-72zn4.codemowers.ee/m/doorboy/swipe" + SLACK_DOORLOG_CALLBACK: DEV + env_file: .env build: context: . diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..88b4c3b --- /dev/null +++ b/requirements.txt @@ -0,0 +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 From ed7c3f0607a657af70cca8925f2105f0ded76c03 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 00:10:02 +0300 Subject: [PATCH 04/12] Move /swipe from inventory-app --- app/doorboy-proxy.py | 77 ++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 69969a2..20e2af9 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -3,6 +3,7 @@ from datetime import date, datetime from sanic import Sanic from sanic.response import text, json from sanic_prometheus import monitor +from dateutil.parser import parse import httpx from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import PyMongoError @@ -20,7 +21,6 @@ WORKSHOP_ACCESS_GROUP = os.environ["WORKSHOP_ACCESS_GROUP"] MONGO_URI = os.environ["MONGO_URI"] INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # asshole forwards to inventory-app, instead of using mongo directly CARD_URI = os.environ["CARD_URI"] -SWIPE_URI = os.environ["SWIPE_URI"] assert len(DOORBOY_SECRET_FLOOR) >= 10 assert len(DOORBOY_SECRET_WORKSHOP) >= 10 @@ -132,8 +132,9 @@ async def view_longpoll(request): 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") -async def forward_swipe(request): +async def swipe(request): # authenticate key = request.headers.get("KEY") if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: @@ -150,41 +151,47 @@ async def forward_swipe(request): print("Door", repr(data.get("door")), "not in", doors) return text("Not allowed", 403) -def slack_post(msg): - if SLACK_DOORLOG_CALLBACK == "DEV": - print(f"[DEV SLACK]: {msg}") - return - - try: - requests.post(SLACK_DOORLOG_CALLBACK, json={"text": msg }).raise_for_status() - except requests.exceptions.RequestException as e: - print(f"[SLACK]: {e}") - -def approvedStr(approved: bool) -> str: - if approved: - return "Permitted" + timestamp = parse(data["timestamp"]) if data.get("timestamp") else datetime.now(datetime.timezone.utc) - return "Denied" - -# consumes SLACK_DOORLOG_CALLBACK and app.ctx.db -@app.listener("after_server_start") -async def slack_log(app, loop): - pipeline = [{ - "$match": { - "operationType": "insert", + # 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 + }, + "$setOnInsert": { + "first_seen": timestamp, + "inventory": { + "claimable": True, + } } - }] - 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")) - slack_post(msg) - - except PyMongoError as e: - print(e) + }, upsert=True) + + token = app.ctx.db.inventory.find_one({ + "component": "doorboy", + "type": "token", + "token.uid_hash": data["uid_hash"] + }) + + event_swipe = { + "component": "doorboy", + "method": "token", + "timestamp": timestamp, + "door": data["door"], + "event": "card-swiped", + "approved": data["approved"], + "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") + } + } + 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) From eebfc9efe65576245c54c258fbb63568c3f13821 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 00:16:48 +0300 Subject: [PATCH 05/12] refactor auth to wrapper --- app/doorboy-proxy.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 20e2af9..1b01a02 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -5,6 +5,7 @@ from sanic.response import text, json from sanic_prometheus import monitor from dateutil.parser import parse import httpx +from functools import wraps from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import PyMongoError import os @@ -32,12 +33,23 @@ async def setup_db(app, loop): # https://github.com/sanic-org/sanic/issues/919 app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() -@app.route("/allowed") -async def view_doorboy_uids(request): - key = request.headers.get("KEY") - if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: - return text("how about no") +def authenticate_door(wrapped): + def decorator(f): + @wraps(f) + async def decorated_function(request, *args, **kwargs): + 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): + # authorize + key = request.headers.get("KEY") groups = [] if key == DOORBOY_SECRET_FLOOR: groups.append(FLOOR_ACCESS_GROUP) @@ -101,12 +113,8 @@ async def view_open_door_events(request): return json(transformed, default=datetime_to_json_formatting) @app.route("/longpoll", stream=True) +@authenticate_door async def view_longpoll(request): - key = request.headers.get("KEY") - if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: - return text("Invalid token") - - # authenticate response = await request.respond(content_type="text/event-stream") await response.send("data: response-generator-started\n\n") pipeline = [ @@ -134,13 +142,10 @@ async def view_longpoll(request): # Called by the door to log a card swipe. Does not decide whether the door should be opened. @app.post("/swipe") +@authenticate_door async def swipe(request): - # authenticate - key = request.headers.get("KEY") - if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]: - return text("Invalid token", status=401) - # authorize + key = request.headers.get("KEY") data = request.json doors = set() if key == DOORBOY_SECRET_FLOOR: From d6807e3f017ae83c025846691cfd09be3161b2df Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 01:05:29 +0300 Subject: [PATCH 06/12] move /cards from inventory-app --- app/doorboy-proxy.py | 49 ++++++++++++++++++++++---------------------- app/users.py | 22 ++++++++++++++++++++ docker-compose.yml | 2 -- requirements.txt | 1 + 4 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 app/users.py diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 1b01a02..5a77a64 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 from datetime import date, datetime -from sanic import Sanic -from sanic.response import text, json -from sanic_prometheus import monitor +from typing import List + from dateutil.parser import parse import httpx from functools import wraps @@ -10,6 +9,7 @@ from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import PyMongoError import os from .slack import add_slack_routes +from .users import users_with_group app = Sanic(__name__) add_slack_routes(app) @@ -21,7 +21,6 @@ FLOOR_ACCESS_GROUP = os.environ["FLOOR_ACCESS_GROUP"] WORKSHOP_ACCESS_GROUP = os.environ["WORKSHOP_ACCESS_GROUP"] MONGO_URI = os.environ["MONGO_URI"] INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # asshole forwards to inventory-app, instead of using mongo directly -CARD_URI = os.environ["CARD_URI"] assert len(DOORBOY_SECRET_FLOOR) >= 10 assert len(DOORBOY_SECRET_WORKSHOP) >= 10 @@ -48,29 +47,29 @@ def authenticate_door(wrapped): @app.route("/allowed") @authenticate_door async def view_doorboy_uids(request): + users = List[str] + # authorize key = request.headers.get("KEY") - groups = [] if key == DOORBOY_SECRET_FLOOR: - groups.append(FLOOR_ACCESS_GROUP) - if key == DOORBOY_SECRET_WORKSHOP: - groups.append(WORKSHOP_ACCESS_GROUP) - if not groups: - return "fail", 500 - async with httpx.AsyncClient() as client: - r = await client.post(CARD_URI, json={ - "groups": groups - }, headers={ - "Content-Type": "application/json", - "Authorization": f"Basic {INVENTORY_API_KEY}" - }) - j = r.json() - allowed_uids = [] - for obj in j: - allowed_uids.append({ - "token": obj["token"] - }) - return json({"allowed_uids": allowed_uids}) + users = users_with_group(FLOOR_ACCESS_GROUP) + elif key == DOORBOY_SECRET_WORKSHOP: + users = users_with_group(WORKSHOP_ACCESS_GROUP) + 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 + } + 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)): @@ -153,7 +152,7 @@ async def swipe(request): if key == DOORBOY_SECRET_WORKSHOP: doors.add("workshopdoor") if data.get("door") not in doors: - print("Door", repr(data.get("door")), "not in", doors) + 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) diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..ef93c3e --- /dev/null +++ b/app/users.py @@ -0,0 +1,22 @@ + +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") + for item in ret["items"]: + if group not in item.get("status", {}).get("groups", []): + continue + + 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 9efa962..753affc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,12 +24,10 @@ services: doorboy_proxy: network_mode: host environment: - INVENTORY_API_KEY: "sptWL6XFxl4b8" DOORBOY_SECRET_FLOOR: "0123456789" DOORBOY_SECRET_WORKSHOP: "9999999999" FLOOR_ACCESS_GROUP: "k-space:floor" WORKSHOP_ACCESS_GROUP: "k-space:workshop" - CARD_URI: "https://inventory-app-72zn4.codemowers.ee/cards" SLACK_DOORLOG_CALLBACK: DEV env_file: .env build: diff --git a/requirements.txt b/requirements.txt index 88b4c3b..1b86442 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python_dateutil==2.9.0 Requests==2.32.4 sanic==25.3.0 sanic_prometheus==0.2.1 +kubernetes From abffe7c594d06e62f592e372c4111715232c3255 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 01:14:46 +0300 Subject: [PATCH 07/12] fmt with ruff --- app/doorboy-proxy.py | 166 +++++++++++++++++++++++++------------------ app/slack.py | 35 ++++++--- app/users.py | 16 +++-- docker-compose.yml | 2 - requirements.txt | 3 +- 5 files changed, 130 insertions(+), 92 deletions(-) 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 From 5afee284b7bae0b1976b9a730dc268aecb6ba591 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 02:35:16 +0300 Subject: [PATCH 08/12] python imports hell --- Dockerfile | 6 +++--- app/doorboy-proxy.py | 10 +++++----- app/{users.py => kube.py} | 0 app/slack.py | 9 +++++---- docker-compose.yml | 3 ++- 5 files changed, 15 insertions(+), 13 deletions(-) rename app/{users.py => kube.py} (100%) diff --git a/Dockerfile b/Dockerfile index 24fd9fc..ba96b4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -FROM harbor.k-space.ee/k-space/microservice-base -RUN pip3 install httpx +FROM harbor.k-space.ee/k-space/microservice-base:latest + WORKDIR /app COPY app /app -CMD /app/doorboy-proxy.py +CMD ["python3", "/app/doorboy-proxy.py"] diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index e6ae3c5..85573e8 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -11,11 +11,11 @@ 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 +import kube +import slack app = Sanic(__name__) -add_slack_routes(app) +slack.add_routes(app) monitor(app).expose_endpoint() # API key for godoor controllers authenticating to k-space:floor @@ -61,9 +61,9 @@ async def view_doorboy_uids(request): # authorize key = request.headers.get("KEY") if key == DOORBOY_SECRET_FLOOR: - users = users_with_group(FLOOR_ACCESS_GROUP) + users = kube.users_with_group(FLOOR_ACCESS_GROUP) elif key == DOORBOY_SECRET_WORKSHOP: - users = users_with_group(WORKSHOP_ACCESS_GROUP) + users = kube.users_with_group(WORKSHOP_ACCESS_GROUP) else: print("WARN: unknown door token in /allowed") return "unknown doorboy secret token", 403 diff --git a/app/users.py b/app/kube.py similarity index 100% rename from app/users.py rename to app/kube.py diff --git a/app/slack.py b/app/slack.py index c34eea3..60be69f 100644 --- a/app/slack.py +++ b/app/slack.py @@ -1,14 +1,15 @@ +import os + +import requests from pymongo.errors import PyMongoError from requests.exceptions import RequestException -import os -import requests # 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") +def add_routes(app): + app.register_listener(slack_log, "after_server_start") def slack_post(msg): diff --git a/docker-compose.yml b/docker-compose.yml index d847d7e..564ae87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: driver: none doorboy_proxy: - network_mode: host environment: DOORBOY_SECRET_FLOOR: "0123456789" DOORBOY_SECRET_WORKSHOP: "9999999999" @@ -28,5 +27,7 @@ services: WORKSHOP_ACCESS_GROUP: "k-space:workshop" SLACK_DOORLOG_CALLBACK: DEV env_file: .env + ports: + - "5000:5000" build: context: . From e52a8af0b47e7914461def4fe8afb1af3a7acbab Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 03:42:08 +0300 Subject: [PATCH 09/12] rename token to card (eventlog) not renaming inventory items, as they need migration refactor eventlog format, harmonized with inventory-app --- app/doorboy-proxy.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 85573e8..d89cdcb 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -150,7 +150,7 @@ async def view_longpoll(request): ev = event["fullDocument"] if ev["approved"] != "true": continue - if ev["type"] == "token": + if ev["type"] == "card": continue response.send("data: %s\n\n" % ev["door"]) @@ -203,18 +203,17 @@ async def swipe(request): event_swipe = { "component": "doorboy", - "method": "token", + "method": "card", "timestamp": timestamp, "door": data["door"], - "event": "card-swiped", "approved": data["approved"], - "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"), }, + "uid_hash": data["uid_hash"], } await app.ctx.db.eventlog.insert_one(event_swipe) From eeeb5ecace98f2e63058d351d844ed1fd13c43ec Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 03:57:20 +0300 Subject: [PATCH 10/12] refactor listen --- app/doorboy-proxy.py | 4 ++-- app/slack.py | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index d89cdcb..f5397df 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -12,10 +12,10 @@ from sanic.response import json, text from sanic_prometheus import monitor import kube -import slack +from slack import slack_app app = Sanic(__name__) -slack.add_routes(app) +app.register_blueprint(slack_app) monitor(app).expose_endpoint() # API key for godoor controllers authenticating to k-space:floor diff --git a/app/slack.py b/app/slack.py index 60be69f..4decfee 100644 --- a/app/slack.py +++ b/app/slack.py @@ -3,15 +3,13 @@ import os import requests from pymongo.errors import PyMongoError from requests.exceptions import RequestException +from sanic import Blueprint + +slack_app = Blueprint("slack", __name__) # webhook logs to private channel or "DEV" to print to console. SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] - -def add_routes(app): - app.register_listener(slack_log, "after_server_start") - - def slack_post(msg): if SLACK_DOORLOG_CALLBACK == "DEV": print(f"[DEV SLACK]: {msg}") From f5cfb3454a14241af40c7fa73ed92d03eff95fa7 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 05:15:29 +0300 Subject: [PATCH 11/12] slack /open-xxx from inventory-app --- README.md | 13 +++++++ app/doorboy-proxy.py | 17 ++++----- app/kube.py | 22 ++++++++++-- app/slack.py | 83 +++++++++++++++++++++++++++++++++++++++++++- docker-compose.yml | 1 + 5 files changed, 125 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c9f9a05..487033f 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,16 @@ docker-compose -f docker-compose.yml up --build On kdoorpi override `KDOORPI_API_ALLOWED`, `KDOORPI_API_LONGPOLL` environment variables to redirect requests to your dev instance. + +# Slack bot +1. https://api.slack.com/apps → Create new app → From scratch +1. Verification Token as `SLACK_VERIFICATION_TOKEN` +1. App home → Bot user + - `commands` + - `users:read` + + + + +1. Add commands. Request URL `https://doorboy-proxy.k-space.ee/slack-open` +1. Incoming Webhooks → assign to channel -> Webhook URL as `SLACK_DOORLOG_CALLBACK` diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index f5397df..efbe163 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -4,14 +4,13 @@ from datetime import date, datetime from functools import wraps from typing import List +import kube from dateutil.parser import parse from motor.motor_asyncio import AsyncIOMotorClient from pymongo.errors import PyMongoError from sanic import Sanic from sanic.response import json, text from sanic_prometheus import monitor - -import kube from slack import slack_app app = Sanic(__name__) @@ -53,6 +52,7 @@ def authenticate_door(wrapped): return decorator(wrapped) +# Door controllers query uid_hashes for offline use. There is no online uid_hash authn/z, only logging. @app.route("/allowed") @authenticate_door async def view_doorboy_uids(request): @@ -73,7 +73,7 @@ async def view_doorboy_uids(request): "inventory.owner.username": {"$in": users}, } prj = {"token.uid_hash": True} - tokens = await app.ctx.db.inventory.find(flt, prj) + tokens = await request.app.ctx.db.inventory.find(flt, prj) print(f"delegating {len(tokens)} doorkey tokens") return json({"allowed_uids": tokens}) @@ -131,6 +131,7 @@ async def view_open_door_events(request): return json(transformed, default=datetime_to_json_formatting) +# Door controllers listen for open events (web, slack). @app.route("/longpoll", stream=True) @authenticate_door async def view_longpoll(request): @@ -144,7 +145,7 @@ async def view_longpoll(request): } ] try: - async with app.ctx.db.eventlog.watch(pipeline) as stream: + async with request.app.ctx.db.eventlog.watch(pipeline) as stream: await response.send("data: watch-stream-opened\n\n") async for event in stream: ev = event["fullDocument"] @@ -160,7 +161,7 @@ async def view_longpoll(request): return -# Called by the door to log a card swipe. Does not decide whether the door should be opened. +# Door controller reporting a card swipe. No authn/z about the event (done offline on door controller), only logging. @app.post("/swipe") @authenticate_door async def swipe(request): @@ -183,7 +184,7 @@ async def swipe(request): ) # Update token, create if unknown - await app.ctx.db.inventory.update_one( + await request.app.ctx.db.inventory.update_one( {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]}, { "$set": {"last_seen": timestamp}, @@ -197,7 +198,7 @@ async def swipe(request): upsert=True, ) - token = await app.ctx.db.inventory.find_one( + token = await request.app.ctx.db.inventory.find_one( {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]} ) @@ -215,7 +216,7 @@ async def swipe(request): }, "uid_hash": data["uid_hash"], } - await app.ctx.db.eventlog.insert_one(event_swipe) + await request.app.ctx.db.eventlog.insert_one(event_swipe) return text("ok") diff --git a/app/kube.py b/app/kube.py index 0752407..fde7b76 100644 --- a/app/kube.py +++ b/app/kube.py @@ -1,6 +1,7 @@ -from typing import List -from kubernetes import client, config import os +from typing import List, Tuple + +from kubernetes import client, config OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") @@ -22,3 +23,20 @@ def users_with_group(group: str) -> List[str]: print(f"INFO: {len(users)} users in group {group}") return users + + +# -> (groups[], username) +def by_slackid(slack_id: str) -> Tuple[List[str], str]: + config.load_incluster_config() + api_instance = client.CustomObjectsApi() + + ret = api_instance.list_namespaced_custom_object( + "codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers" + ) + for item in ret["items"]: + if slack_id == item.get("status", {}).get("slackId", None): + return item.get("status", {}).get("groups", []), item.get( + "metadata", {} + ).get("name", "") + + return [], "" diff --git a/app/slack.py b/app/slack.py index 4decfee..394039d 100644 --- a/app/slack.py +++ b/app/slack.py @@ -1,5 +1,8 @@ import os +from datetime import datetime +from typing import Tuple +import kube import requests from pymongo.errors import PyMongoError from requests.exceptions import RequestException @@ -9,6 +12,10 @@ slack_app = Blueprint("slack", __name__) # webhook logs to private channel or "DEV" to print to console. SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] +# used to verify (deprecated) incoming requests from slack +SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"] +SLACK_CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"] # TODO: + def slack_post(msg): if SLACK_DOORLOG_CALLBACK == "DEV": @@ -29,7 +36,8 @@ def approvedStr(approved: bool) -> str: # consumes SLACK_DOORLOG_CALLBACK and app.ctx.db -async def slack_log(app, loop): +@slack_app.listener("after_server_start") +async def slack_log_fwd(app, loop): pipeline = [ { "$match": { @@ -53,3 +61,76 @@ async def slack_log(app, loop): except PyMongoError as e: print(e) + + +def authz_special(authzGroup, userGroups, user) -> Tuple[bool, str]: + if authzGroup not in userGroups: + return False, f"You are not in {authzGroup}. k-space.ee/membership" + + return True, user + + +# -> approved, username +# -> not approved, error message +def slack_authz(user_id: str, channel_id: str, door: str) -> Tuple[bool, str]: + if door in ["alldoors", "backdoor", "frontdoor", "grounddoor"]: + if channel_id == SLACK_CHANNEL_ID: + return True + + groups, user = kube.by_slackid(user_id) + if "k-space:floor" not in groups: + return ( + False, + "No user with slack_id %s. Try in #members or doorboy.k-space.ee.", + ) + + return True, user + + groups, user = kube.by_slackid(user_id) + if user == "": + return False, "No user with slack_id %s. Try doorboy.k-space.ee." + + if door == "workshopdoor": + return authz_special("k-space:workshop", groups, user) + + return False, "Invalid door (git.k-space.ee/k-space/doorboy-proxy)" + + +@slack_app.route("/slack-open", methods=["POST"]) +async def slack_open(request): + if request.form.get("token") != SLACK_VERIFICATION_TOKEN: + return "Invalid token (are you Slack?)", 401 + + command = request.form.get("command") + door = command.removeprefix("/open-").replace("-", "") + + # user may be empty if authzed to SLACK_CHANNEL_ID + ok, userOrErrorMsg = slack_authz( + request.form.get("user_id"), + request.form.get("channel_id"), + door, + ) + if not ok: + return userOrErrorMsg, 403 + + doors = [door] + if door == "alldoors": + # outside non-special doors + doors = ["backdoor", "frontdoor", "grounddoor"] + + for d in doors: + await request.app.ctx.db.eventlog.insert_one( + { + "component": "doorboy", + "method": "slack", + "timestamp": datetime.now(datetime.timezone.utc), + "door": d, + "approved": True, + "user": { + "id": userOrErrorMsg, + "name": request.form.get("user_name"), + }, + } + ) + + return f"Opening {door}…" diff --git a/docker-compose.yml b/docker-compose.yml index 564ae87..5f76146 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: FLOOR_ACCESS_GROUP: "k-space:floor" WORKSHOP_ACCESS_GROUP: "k-space:workshop" SLACK_DOORLOG_CALLBACK: DEV + SLACK_CHANNEL_ID: CDL9H8Q9W env_file: .env ports: - "5000:5000" From 4e493069ab212a215f24be0086dcd755ba0ced88 Mon Sep 17 00:00:00 2001 From: rasmus Date: Fri, 8 Aug 2025 05:40:45 +0300 Subject: [PATCH 12/12] groups doc --- README.md | 6 +++++- app/doorboy-proxy.py | 6 ++---- app/kube.py | 2 +- docker-compose.yml | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 487033f..286e46d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ docker-compose -f docker-compose.yml up --build On kdoorpi override `KDOORPI_API_ALLOWED`, `KDOORPI_API_LONGPOLL` environment variables to redirect requests to your dev instance. -# Slack bot +# Deployment +## Slack credentials 1. https://api.slack.com/apps → Create new app → From scratch 1. Verification Token as `SLACK_VERIFICATION_TOKEN` 1. App home → Bot user @@ -47,3 +48,6 @@ to redirect requests to your dev instance. 1. Add commands. Request URL `https://doorboy-proxy.k-space.ee/slack-open` 1. Incoming Webhooks → assign to channel -> Webhook URL as `SLACK_DOORLOG_CALLBACK` + +## OIDC groups +Assumes `k-space:floor` and `k-space:workshop`, same in inventory-app. diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index efbe163..8ebf515 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -21,8 +21,6 @@ monitor(app).expose_endpoint() 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"] @@ -61,9 +59,9 @@ async def view_doorboy_uids(request): # authorize key = request.headers.get("KEY") if key == DOORBOY_SECRET_FLOOR: - users = kube.users_with_group(FLOOR_ACCESS_GROUP) + users = kube.users_with_group("k-space:floor") elif key == DOORBOY_SECRET_WORKSHOP: - users = kube.users_with_group(WORKSHOP_ACCESS_GROUP) + users = kube.users_with_group("k-space:workshop") else: print("WARN: unknown door token in /allowed") return "unknown doorboy secret token", 403 diff --git a/app/kube.py b/app/kube.py index fde7b76..5ad9290 100644 --- a/app/kube.py +++ b/app/kube.py @@ -3,7 +3,7 @@ from typing import List, Tuple from kubernetes import client, config -OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") +OIDC_USERS_NAMESPACE = os.environ["OIDC_USERS_NAMESPACE"] def users_with_group(group: str) -> List[str]: diff --git a/docker-compose.yml b/docker-compose.yml index 5f76146..3ad4e70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,10 +21,10 @@ services: doorboy_proxy: environment: + OIDC_USERS_NAMESPACE: passmower DOORBOY_SECRET_FLOOR: "0123456789" DOORBOY_SECRET_WORKSHOP: "9999999999" - FLOOR_ACCESS_GROUP: "k-space:floor" - WORKSHOP_ACCESS_GROUP: "k-space:workshop" + SLACK_VERIFICATION_TOKEN: DEV SLACK_DOORLOG_CALLBACK: DEV SLACK_CHANNEL_ID: CDL9H8Q9W env_file: .env