Compare commits

..

12 Commits

Author SHA1 Message Date
cb1f84f60b groups doc 2025-08-08 05:41:56 +03:00
f5cfb3454a slack /open-xxx from inventory-app 2025-08-08 05:33:17 +03:00
eeeb5ecace refactor listen 2025-08-08 03:57:20 +03:00
e52a8af0b4 rename token to card (eventlog)
not renaming inventory items, as they
need migration

refactor eventlog format, harmonized
with inventory-app
2025-08-08 03:46:37 +03:00
5afee284b7 python imports hell 2025-08-08 03:46:37 +03:00
abffe7c594 fmt with ruff 2025-08-08 03:46:37 +03:00
d6807e3f01 move /cards from inventory-app 2025-08-08 03:46:37 +03:00
eebfc9efe6 refactor auth to wrapper 2025-08-08 03:46:37 +03:00
ed7c3f0607 Move /swipe from inventory-app 2025-08-08 03:46:37 +03:00
988b7e964e refactor slack logger 2025-08-08 03:46:37 +03:00
55ca235946 tiny refactor /longpoltiny and /longpolll 2025-08-08 03:46:33 +03:00
829da1f55f disable /open-door-events
not in use anywhere
rename to /logs
2025-08-07 20:43:45 +03:00
8 changed files with 371 additions and 107 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env .env
.overnodebundle .overnodebundle
.venv

View File

@@ -1,5 +1,5 @@
FROM harbor.k-space.ee/k-space/microservice-base FROM harbor.k-space.ee/k-space/microservice-base:latest
RUN pip3 install httpx
WORKDIR /app WORKDIR /app
COPY app /app COPY app /app
CMD /app/doorboy-proxy.py CMD ["python3", "/app/doorboy-proxy.py"]

View File

@@ -34,3 +34,20 @@ 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.
# 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
- `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`
## OIDC groups
Assumes `k-space:floor` and `k-space:workshop`, same in inventory-app.

View File

@@ -1,25 +1,28 @@
#!/usr/bin/env python3 #!/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 motor.motor_asyncio import AsyncIOMotorClient
import httpx
import pymongo
import os import os
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
from slack import slack_app
app = Sanic(__name__) app = Sanic(__name__)
app.register_blueprint(slack_app)
monitor(app).expose_endpoint() monitor(app).expose_endpoint()
INVENTORY_API_KEY = os.environ["INVENTORY_API_KEY"] # API key for godoor controllers authenticating to k-space:floor
DOORBOY_SECRET_FLOOR = os.environ["DOORBOY_SECRET_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"] 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"]
MONGO_URI = os.environ["MONGO_URI"] MONGO_URI = os.environ["MONGO_URI"]
SWIPE_URI = os.environ["SWIPE_URI"]
assert len(DOORBOY_SECRET_FLOOR) >= 10 assert len(DOORBOY_SECRET_FLOOR) >= 10
assert len(DOORBOY_SECRET_WORKSHOP) >= 10 assert len(DOORBOY_SECRET_WORKSHOP) >= 10
@@ -31,84 +34,105 @@ 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()
@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")
groups = [] 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)
# 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):
users = List[str]
# authorize
key = request.headers.get("KEY")
if key == DOORBOY_SECRET_FLOOR: if key == DOORBOY_SECRET_FLOOR:
groups.append(FLOOR_ACCESS_GROUP) users = kube.users_with_group("k-space:floor")
if key == DOORBOY_SECRET_WORKSHOP: elif key == DOORBOY_SECRET_WORKSHOP:
groups.append(WORKSHOP_ACCESS_GROUP) users = kube.users_with_group("k-space:workshop")
if not groups: else:
return "fail", 500 print("WARN: unknown door token in /allowed")
async with httpx.AsyncClient() as client: return "unknown doorboy secret token", 403
r = await client.post(CARD_URI, json={
"groups": groups flt = {
}, headers={ "token.uid_hash": {"$exists": True},
"Content-Type": "application/json", "inventory.owner.username": {"$in": users},
"Authorization": f"Basic {INVENTORY_API_KEY}" }
}) prj = {"token.uid_hash": True}
j = r.json() tokens = await request.app.ctx.db.inventory.find(flt, prj)
allowed_uids = []
for obj in j: print(f"delegating {len(tokens)} doorkey tokens")
allowed_uids.append({ return json({"allowed_uids": tokens})
"token": obj["token"]
})
return json({"allowed_uids": allowed_uids})
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()
@app.route("/open-door-events")
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")
results = await app.ctx.db.eventlog.find({ # 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", "component": "doorboy",
"type": "open-door", "type": "open-door",
"approved": True,
"$or": [ "$or": [
{ "approved": True }, {"type": "open-door"},
{ "success": True }, {"event": "card-swiped"},
], ],
"$or": [ "door": {"$exists": True},
{ "type": "open-door" }, "timestamp": {"$exists": True},
{ "event": "card-swiped" }, }
], )
"door": { "$exists": True }, .sort("timestamp", -1)
"timestamp": { "$exists": True } .to_list(length=None)
}).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"), "method": r.get("method"),
"door": r["door"], "door": r["door"],
"timestamp": r.get("timestamp"), "timestamp": r.get("timestamp"),
"member": r.get("member"), "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", "method": "card-swiped",
"door": r["door"], "door": r["door"],
"timestamp": r.get("timestamp"), "timestamp": r.get("timestamp"),
"member": r.get("inventory", {}).get("owner") "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)
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")
# Door controllers listen for open events (web, slack).
@app.route("/longpoll", stream=True)
@authenticate_door
async def view_longpoll(request):
response = await request.respond(content_type="text/event-stream") response = await request.respond(content_type="text/event-stream")
await response.send("data: response-generator-started\n\n") await response.send("data: response-generator-started\n\n")
pipeline = [ pipeline = [
@@ -119,22 +143,28 @@ 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:
if event["fullDocument"].get("type") == "open-door" and event["fullDocument"].get("approved", False): ev = event["fullDocument"]
await response.send("data: %s\n\n" % if ev["approved"] != "true":
event["fullDocument"]["door"]) continue
except pymongo.errors.PyMongoError as e: if ev["type"] == "card":
continue
response.send("data: %s\n\n" % ev["door"])
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
# 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")
async def forward_swipe(request): @authenticate_door
async def swipe(request):
# authorize
key = request.headers.get("KEY") key = request.headers.get("KEY")
if not key or key not in [DOORBOY_SECRET_FLOOR, DOORBOY_SECRET_WORKSHOP]:
return text("Invalid token", status=401)
data = request.json data = request.json
doors = set() doors = set()
if key == DOORBOY_SECRET_FLOOR: if key == DOORBOY_SECRET_FLOOR:
@@ -142,19 +172,54 @@ async def forward_swipe(request):
if key == DOORBOY_SECRET_WORKSHOP: if key == DOORBOY_SECRET_WORKSHOP:
doors.add("workshopdoor") doors.add("workshopdoor")
if data.get("door") not in doors: 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) return text("Not allowed", 403)
async with httpx.AsyncClient() as client: timestamp = (
r = await client.post(SWIPE_URI, json=data, headers={ parse(data["timestamp"])
"Content-Type": "application/json", if data.get("timestamp")
"Authorization": f"Basic {INVENTORY_API_KEY}" else datetime.now(datetime.timezone.utc)
}) )
if r.status_code == 200:
# Update token, create if unknown
await request.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,
},
},
},
upsert=True,
)
token = await request.app.ctx.db.inventory.find_one(
{"component": "doorboy", "type": "token", "token.uid_hash": data["uid_hash"]}
)
event_swipe = {
"component": "doorboy",
"method": "card",
"timestamp": timestamp,
"door": data["door"],
"approved": data["approved"],
"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 request.app.ctx.db.eventlog.insert_one(event_swipe)
return text("ok") return text("ok")
else:
return text("Failed", 500)
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
)

42
app/kube.py Normal file
View File

@@ -0,0 +1,42 @@
import os
from typing import List, Tuple
from kubernetes import client, config
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
# -> (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 [], ""

136
app/slack.py Normal file
View File

@@ -0,0 +1,136 @@
import os
from datetime import datetime
from typing import Tuple
import kube
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"]
# 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":
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"
# consumes SLACK_DOORLOG_CALLBACK and app.ctx.db
@slack_app.listener("after_server_start")
async def slack_log_fwd(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)
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

@@ -1,5 +1,3 @@
version: '3.7'
services: services:
mongoexpress: mongoexpress:
image: mongo-express image: mongo-express
@@ -22,15 +20,13 @@ services:
driver: none driver: none
doorboy_proxy: doorboy_proxy:
network_mode: host
environment: environment:
INVENTORY_API_KEY: "sptWL6XFxl4b8"
DOORBOY_SECRET_FLOOR: "0123456789" DOORBOY_SECRET_FLOOR: "0123456789"
DOORBOY_SECRET_WORKSHOP: "9999999999" DOORBOY_SECRET_WORKSHOP: "9999999999"
DOORBOY_SECRET_OPEN_EVENTS: "1111111111" SLACK_DOORLOG_CALLBACK: DEV
FLOOR_ACCESS_GROUP: "k-space:floor" SLACK_CHANNEL_ID: CDL9H8Q9W
WORKSHOP_ACCESS_GROUP: "k-space:workshop" env_file: .env
CARD_URI: "https://inventory-app-72zn4.codemowers.ee/cards" ports:
SWIPE_URI: "https://inventory-app-72zn4.codemowers.ee/m/doorboy/swipe" - "5000:5000"
build: build:
context: . context: .

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
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==33.1.0