Compute keep_open_until in /allowed + 9 bugfixes #8

Open
mykhailo wants to merge 1 commits from mykhailo/doorboy-proxy:issue-1-keep-open into master
5 changed files with 75 additions and 80 deletions

View File

@@ -18,7 +18,7 @@ When updating doorboy proxy, members site or kdoorpi verify follwing:
* Card enable/disable on members site works and has effect * 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 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 * 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 When testing changes prefer using the *back* door and
use a brick or something to keep it open to prevent use a brick or something to keep it open to prevent

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
from datetime import date, datetime, timezone from datetime import datetime, timezone
from functools import wraps from functools import wraps
from typing import List from typing import List
@@ -24,8 +24,10 @@ DOORBOY_SECRET_WORKSHOP = os.environ["DOORBOY_SECRET_WORKSHOP"]
MONGO_URI = os.environ["MONGO_URI"] MONGO_URI = os.environ["MONGO_URI"]
assert len(DOORBOY_SECRET_FLOOR) >= 10 if len(DOORBOY_SECRET_FLOOR) < 10:
assert len(DOORBOY_SECRET_WORKSHOP) >= 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") @app.listener("before_server_start")
@@ -35,8 +37,7 @@ async def setup_db(app):
app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database() app.ctx.db = AsyncIOMotorClient(MONGO_URI).get_default_database()
def authenticate_door(wrapped): def authenticate_door(f):
def decorator(f):
@wraps(f) @wraps(f)
async def decorated_function(request, *args, **kwargs): async def decorated_function(request, *args, **kwargs):
doorboy_secret = request.headers.get("KEY") doorboy_secret = request.headers.get("KEY")
@@ -47,8 +48,6 @@ def authenticate_door(wrapped):
return decorated_function return decorated_function
return decorator(wrapped)
# Door controllers query uid_hashes for offline use. There is no online uid_hash authn/z, only logging. # Door controllers query uid_hashes for offline use. There is no online uid_hash authn/z, only logging.
@app.route("/allowed") @app.route("/allowed")
@@ -64,7 +63,7 @@ async def view_doorboy_uids(request):
users = kube.users_with_group("k-space:workshop") users = kube.users_with_group("k-space:workshop")
else: else:
print("WARN: unknown door token in /allowed") print("WARN: unknown door token in /allowed")
return "unknown doorboy secret token", 403 return text("unknown doorboy secret token", status=403)
flt = { flt = {
"token.uid_hash": {"$exists": True}, "token.uid_hash": {"$exists": True},
@@ -78,60 +77,38 @@ async def view_doorboy_uids(request):
doorname = request.headers.get("DOOR_NAME") doorname = request.headers.get("DOOR_NAME")
print(f"delegating {len(tokens)} doorkey tokens to {doorname}") 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): keep_open_until = None
if isinstance(o, (date, datetime)): if doorname in door_set:
return o.isoformat() 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.** # 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") @app.route("/logs")
async def view_open_door_events(request): async def view_open_door_events(request):
return text("not an admin"), 403 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). # Door controllers listen for open events (web, slack).
@app.route("/longpoll", stream=True) @app.route("/longpoll", stream=True)
@@ -155,6 +132,8 @@ async def view_longpoll(request):
continue continue
if ev["method"] == "card": if ev["method"] == "card":
continue continue
if ev["method"] == "hold":
continue
print("realtime opening %s" % ev["door"]) print("realtime opening %s" % ev["door"])
await response.send("data: %s\n\n" % ev["door"]) await response.send("data: %s\n\n" % ev["door"])

View File

@@ -1,10 +1,21 @@
import os import os
from typing import List, Tuple from typing import List, Optional, Tuple
from kubernetes import client, config from kubernetes import client, config
OIDC_USERS_NAMESPACE = os.environ["OIDC_USERS_NAMESPACE"] 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]: def groupsToFullName(groups) -> List[str]:
fullName: List[str] = [] fullName: List[str] = []
@@ -15,17 +26,21 @@ def groupsToFullName(groups) -> List[str]:
return fullName 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() api_instance = client.CustomObjectsApi()
users: List[str] = []
ret = api_instance.list_namespaced_custom_object( ret = api_instance.list_namespaced_custom_object(
"codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers" "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", [])): for group in groupsToFullName(item.get("status", {}).get("groups", [])):
if group == requiredGroup: if group == requiredGroup:
users.append(item["metadata"]["name"]) 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}") print(f"INFO: {len(users)} users in group {requiredGroup}")
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( # -> (groups[], username)
"codemowers.cloud", "v1beta1", OIDC_USERS_NAMESPACE, "oidcusers" def by_slackid(slack_id: str) -> Tuple[List[str], Optional[str]]:
) for item in _get_users():
for item in ret["items"]:
if slack_id == item.get("status", {}).get("slackId", None): if slack_id == item.get("status", {}).get("slackId", None):
return groupsToFullName(item.get("status", {}).get("groups", [])), item.get("metadata", {}).get("name", "") return groupsToFullName(item.get("status", {}).get("groups", [])), item.get("metadata", {}).get("name", "")
return [], "" return [], None

View File

@@ -1,3 +1,4 @@
import asyncio
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Tuple from typing import Tuple
@@ -70,6 +71,7 @@ async def slack_log_fwd(app):
except PyMongoError as e: except PyMongoError as e:
print(e) print(e)
await asyncio.sleep(5)
# -> approved, user, err # -> approved, user, err
def slack_authz(authGroup: str, slackId: str, channel_id: str) -> Tuple[bool, str, str]: 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 return "Invalid token (are you Slack?)", 401
command = request.form.get("command") 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("-", "") door = command.removeprefix("/open-").replace("-", "")
authGroup = fauthGroup(door) authGroup = fauthGroup(door)
@@ -110,7 +116,7 @@ async def slack_open(request):
request.form.get("channel_id"), 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] doors = [door]
if door == "alldoors": if door == "alldoors":

View File

@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
mongo <<EOF mongosh --quiet <<EOF
rs.initiate({ rs.initiate({
_id: 'rs0', _id: 'rs0',
members: [ members: [