slack /open-xxx from inventory-app
This commit is contained in:
		
							
								
								
									
										13
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								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 | On kdoorpi override `KDOORPI_API_ALLOWED`, `KDOORPI_API_LONGPOLL` environment variables | ||||||
| to redirect requests to your dev instance. | 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` | ||||||
|  |   <!-- `users:read_email` --> | ||||||
|  |   <!-- `channel:read` --> | ||||||
|  |   <!-- `groups:read` --> | ||||||
|  |   <!-- `incoming-webhook` --> | ||||||
|  | 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` | ||||||
|   | |||||||
| @@ -4,14 +4,13 @@ from datetime import date, datetime | |||||||
| from functools import wraps | from functools import wraps | ||||||
| from typing import List | from typing import List | ||||||
|  |  | ||||||
|  | import kube | ||||||
| from dateutil.parser import parse | from dateutil.parser import parse | ||||||
| from motor.motor_asyncio import AsyncIOMotorClient | from motor.motor_asyncio import AsyncIOMotorClient | ||||||
| from pymongo.errors import PyMongoError | from pymongo.errors import PyMongoError | ||||||
| from sanic import Sanic | from sanic import Sanic | ||||||
| from sanic.response import json, text | from sanic.response import json, text | ||||||
| from sanic_prometheus import monitor | from sanic_prometheus import monitor | ||||||
|  |  | ||||||
| import kube |  | ||||||
| from slack import slack_app | from slack import slack_app | ||||||
|  |  | ||||||
| app = Sanic(__name__) | app = Sanic(__name__) | ||||||
| @@ -53,6 +52,7 @@ def authenticate_door(wrapped): | |||||||
|     return decorator(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") | @app.route("/allowed") | ||||||
| @authenticate_door | @authenticate_door | ||||||
| async def view_doorboy_uids(request): | async def view_doorboy_uids(request): | ||||||
| @@ -73,7 +73,7 @@ async def view_doorboy_uids(request): | |||||||
|         "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 request.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}) | ||||||
| @@ -131,6 +131,7 @@ async def view_open_door_events(request): | |||||||
|     return json(transformed, default=datetime_to_json_formatting) |     return json(transformed, default=datetime_to_json_formatting) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Door controllers listen for open events (web, slack). | ||||||
| @app.route("/longpoll", stream=True) | @app.route("/longpoll", stream=True) | ||||||
| @authenticate_door | @authenticate_door | ||||||
| async def view_longpoll(request): | async def view_longpoll(request): | ||||||
| @@ -144,7 +145,7 @@ async def view_longpoll(request): | |||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|     try: |     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") |             await response.send("data: watch-stream-opened\n\n") | ||||||
|             async for event in stream: |             async for event in stream: | ||||||
|                 ev = event["fullDocument"] |                 ev = event["fullDocument"] | ||||||
| @@ -160,7 +161,7 @@ async def view_longpoll(request): | |||||||
|         return |         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") | @app.post("/swipe") | ||||||
| @authenticate_door | @authenticate_door | ||||||
| async def swipe(request): | async def swipe(request): | ||||||
| @@ -183,7 +184,7 @@ async def swipe(request): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Update token, create if unknown |     # 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"]}, |         {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]}, | ||||||
|         { |         { | ||||||
|             "$set": {"last_seen": timestamp}, |             "$set": {"last_seen": timestamp}, | ||||||
| @@ -197,7 +198,7 @@ async def swipe(request): | |||||||
|         upsert=True, |         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"]} |         {"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]} | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @@ -215,7 +216,7 @@ async def swipe(request): | |||||||
|         }, |         }, | ||||||
|         "uid_hash": data["uid_hash"], |         "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") |     return text("ok") | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								app/kube.py
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								app/kube.py
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| from typing import List |  | ||||||
| from kubernetes import client, config |  | ||||||
| import os | import os | ||||||
|  | from typing import List, Tuple | ||||||
|  |  | ||||||
|  | from kubernetes import client, config | ||||||
|  |  | ||||||
| OIDC_USERS_NAMESPACE = os.getenv("OIDC_USERS_NAMESPACE") | 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}") |     print(f"INFO: {len(users)} users in group {group}") | ||||||
|     return users |     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 [], "" | ||||||
|   | |||||||
							
								
								
									
										83
									
								
								app/slack.py
									
									
									
									
									
								
							
							
						
						
									
										83
									
								
								app/slack.py
									
									
									
									
									
								
							| @@ -1,5 +1,8 @@ | |||||||
| import os | import os | ||||||
|  | from datetime import datetime | ||||||
|  | from typing import Tuple | ||||||
|  |  | ||||||
|  | import kube | ||||||
| import requests | import requests | ||||||
| from pymongo.errors import PyMongoError | from pymongo.errors import PyMongoError | ||||||
| from requests.exceptions import RequestException | 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. | # webhook logs to private channel or "DEV" to print to console. | ||||||
| SLACK_DOORLOG_CALLBACK = os.environ["SLACK_DOORLOG_CALLBACK"] | 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): | def slack_post(msg): | ||||||
|     if SLACK_DOORLOG_CALLBACK == "DEV": |     if SLACK_DOORLOG_CALLBACK == "DEV": | ||||||
| @@ -29,7 +36,8 @@ def approvedStr(approved: bool) -> str: | |||||||
|  |  | ||||||
|  |  | ||||||
| # consumes SLACK_DOORLOG_CALLBACK and app.ctx.db | # 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 = [ |     pipeline = [ | ||||||
|         { |         { | ||||||
|             "$match": { |             "$match": { | ||||||
| @@ -53,3 +61,76 @@ async def slack_log(app, loop): | |||||||
|  |  | ||||||
|         except PyMongoError as e: |         except PyMongoError as e: | ||||||
|             print(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}…" | ||||||
|   | |||||||
| @@ -26,6 +26,7 @@ services: | |||||||
|       FLOOR_ACCESS_GROUP: "k-space:floor" |       FLOOR_ACCESS_GROUP: "k-space:floor" | ||||||
|       WORKSHOP_ACCESS_GROUP: "k-space:workshop" |       WORKSHOP_ACCESS_GROUP: "k-space:workshop" | ||||||
|       SLACK_DOORLOG_CALLBACK: DEV |       SLACK_DOORLOG_CALLBACK: DEV | ||||||
|  |       SLACK_CHANNEL_ID: CDL9H8Q9W | ||||||
|     env_file: .env |     env_file: .env | ||||||
|     ports: |     ports: | ||||||
|      - "5000:5000" |      - "5000:5000" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user