doorboy-direct #5

Open
rasmus wants to merge 12 commits from doorboy-direct into master
8 changed files with 369 additions and 105 deletions
Showing only changes of commit f5cfb3454a - Show all commits

View File

@@ -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`

View File

@@ -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")

View File

@@ -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 [], ""

View File

@@ -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}"

View File

@@ -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"