diff --git a/README.md b/README.md index 286e46d..08f14f6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ When updating doorboy proxy, members site or kdoorpi verify follwing: * Card enable/disable on members site works and has effect * Opening door via buttons at https://members.k-space.ee/m/doorboy works and has effect * Opening door via `/open-ground-door`, `/open-front-door` and `/open-back-door` commands in Slack channel #members works -* TODO: Keep door open via members site works and has effect +* Keep door open via members site works and has effect: submit a hold, verify `/allowed` returns `keep_open_until`, then verify cancel/expiry returns `null`. When testing changes prefer using the *back* door and use a brick or something to keep it open to prevent diff --git a/app/doorboy-proxy.py b/app/doorboy-proxy.py index 6c919a5..a3646d6 100755 --- a/app/doorboy-proxy.py +++ b/app/doorboy-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import os -from datetime import date, datetime, timezone +from datetime import datetime, timezone from functools import wraps from typing import List @@ -24,8 +24,10 @@ DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"] MONGO_URI = os.environ["MONGO_URI"] -assert len(DOORBOY_SECRET_FLOOR) >= 10 -assert len(DOORBOY_SECRET_WORKSHOP) >= 10 +if len(DOORBOY_SECRET_FLOOR) < 10: + raise ValueError("DOORBOY_SECRET_FLOOR must be at least 10 characters") +if len(DOORBOY_SECRET_WORKSHOP) < 10: + raise ValueError("DOORBOY_SECRET_WORKSHOP must be at least 10 characters") @app.listener("before_server_start") @@ -35,19 +37,16 @@ async def setup_db(app): app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() -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) +def authenticate_door(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 await f(request, *args, **kwargs) - return decorated_function - - return decorator(wrapped) + return decorated_function # Door controllers query uid_hashes for offline use. There is no online uid_hash authn/z, only logging. @@ -64,7 +63,7 @@ async def view_doorboy_uids(request): users = kube.users_with_group("k-space:workshop") else: print("WARN: unknown door token in /allowed") - return "unknown doorboy secret token", 403 + return text("unknown doorboy secret token", status=403) flt = { "token.uid_hash": {"$exists": True}, @@ -78,60 +77,38 @@ async def view_doorboy_uids(request): doorname = request.headers.get("DOOR_NAME") print(f"delegating {len(tokens)} doorkey tokens to {doorname}") - return json({"allowed_hashes": tokens}) + # Determine which doors this key controls, then compute keep_open_until + # for the requesting controller's door (newest active approved hold wins). + if key == DOORBOY_SECRET_FLOOR: + door_set = {"backdoor", "frontdoor", "grounddoor"} + elif key == DOORBOY_SECRET_WORKSHOP: + door_set = {"workshopdoor"} + else: + door_set = set() -def datetime_to_json_formatting(o): - if isinstance(o, (date, datetime)): - return o.isoformat() + keep_open_until = None + if doorname in door_set: + hold = await request.app.ctx.db.doorlog.find_one( + {"method": "hold", "door": doorname, "approved": True}, + sort=[("timestamp", -1)], + ) + # DB datetimes are naive UTC; attach tzinfo only for the serialized + # output per integration contract. + now = datetime.now(timezone.utc).replace(tzinfo=None) + if hold and hold.get("expires") and hold["expires"] > now: + keep_open_until = hold["expires"].replace(tzinfo=timezone.utc).isoformat() + + return json({"allowed_hashes": tokens, "keep_open_until": keep_open_until}) # Only identified and successful events. **Endpoint not in use.** +# The query logic below is preserved as reference for future re-enablement; +# see git history for the original implementation. @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) - ) - - 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"), - } - ) - 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"), - } - ) - - return json(transformed, default=datetime_to_json_formatting) - # Door controllers listen for open events (web, slack). @app.route("/longpoll", stream=True) @@ -155,7 +132,9 @@ async def view_longpoll(request): continue if ev["method"] == "card": continue - + if ev["method"] == "hold": + continue + print("realtime opening %s" % ev["door"]) await response.send("data: %s\n\n" % ev["door"]) except PyMongoError as e: diff --git a/app/kube.py b/app/kube.py index ea02b3f..c038a00 100644 --- a/app/kube.py +++ b/app/kube.py @@ -1,10 +1,21 @@ import os -from typing import List, Tuple +from typing import List, Optional, Tuple from kubernetes import client, config OIDC_USERS_NAMESPACE = os.environ["OIDC_USERS_NAMESPACE"] +_config_loaded = False + + +def _ensure_config(): + """Load in-cluster Kubernetes config exactly once (lazy, cached).""" + global _config_loaded + if not _config_loaded: + config.load_incluster_config() + _config_loaded = True + + def groupsToFullName(groups) -> List[str]: fullName: List[str] = [] @@ -15,17 +26,21 @@ def groupsToFullName(groups) -> List[str]: return fullName -def users_with_group(requiredGroup: str) -> List[str]: - config.load_incluster_config() + +def _get_users() -> list: + """Return all OIDC user items from the Kubernetes API.""" + _ensure_config() api_instance = client.CustomObjectsApi() - - users: List[str] = [] - ret = api_instance.list_namespaced_custom_object( "codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers" ) + return ret["items"] - for item in ret["items"]: + +def users_with_group(requiredGroup: str) -> List[str]: + users: List[str] = [] + + for item in _get_users(): for group in groupsToFullName(item.get("status", {}).get("groups", [])): if group == requiredGroup: users.append(item["metadata"]["name"]) @@ -34,16 +49,11 @@ def users_with_group(requiredGroup: str) -> List[str]: print(f"INFO: {len(users)} users in group {requiredGroup}") 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"]: +# -> (groups[], username) +def by_slackid(slack_id: str) -> Tuple[List[str], Optional[str]]: + for item in _get_users(): if slack_id == item.get("status", {}).get("slackId", None): return groupsToFullName(item.get("status", {}).get("groups", [])), item.get("metadata", {}).get("name", "") - return [], "" + return [], None diff --git a/app/slack.py b/app/slack.py index 4db7584..ee87a0c 100644 --- a/app/slack.py +++ b/app/slack.py @@ -1,3 +1,4 @@ +import asyncio import os from datetime import datetime, timezone from typing import Tuple @@ -70,6 +71,7 @@ async def slack_log_fwd(app): except PyMongoError as e: print(e) + await asyncio.sleep(5) # -> approved, user, err def slack_authz(authGroup: str, slackId: str, channel_id: str) -> Tuple[bool, str, str]: @@ -96,6 +98,10 @@ async def slack_open(request): return "Invalid token (are you Slack?)", 401 command = request.form.get("command") + if not command: + print("WARN: /slack-open route accessed without command") + return text("Missing command", status=400) + door = command.removeprefix("/open-").replace("-", "") authGroup = fauthGroup(door) @@ -110,7 +116,7 @@ async def slack_open(request): request.form.get("channel_id"), ) - userExtra = f"{request.form.get("user_id")} (slack u/n: {request.form.get("user_name")})" # slackName can be changed by user + userExtra = f"{request.form.get('user_id')} (slack u/n: {request.form.get('user_name')})" # slackName can be changed by user doors = [door] if door == "alldoors": diff --git a/mongo-init.sh b/mongo-init.sh index 2643b27..300f10e 100644 --- a/mongo-init.sh +++ b/mongo-init.sh @@ -1,5 +1,5 @@ #!/bin/bash -mongo <