doorboy-direct #5
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,2 +1,3 @@
 | 
				
			|||||||
.env
 | 
					.env
 | 
				
			||||||
.overnodebundle
 | 
					.overnodebundle
 | 
				
			||||||
 | 
					.venv
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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"]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							@@ -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.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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",
 | 
				
			||||||
        "$or": [
 | 
					                "approved": True,
 | 
				
			||||||
            { "approved": True },
 | 
					 | 
				
			||||||
            { "success": True },
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
                "$or": [
 | 
					                "$or": [
 | 
				
			||||||
                    {"type": "open-door"},
 | 
					                    {"type": "open-door"},
 | 
				
			||||||
                    {"event": "card-swiped"},
 | 
					                    {"event": "card-swiped"},
 | 
				
			||||||
                ],
 | 
					                ],
 | 
				
			||||||
                "door": {"$exists": True},
 | 
					                "door": {"$exists": True},
 | 
				
			||||||
        "timestamp": { "$exists": True }
 | 
					                "timestamp": {"$exists": True},
 | 
				
			||||||
    }).sort("timestamp", -1).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
									
								
							
							
						
						
									
										42
									
								
								app/kube.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					from typing import List, Tuple
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from kubernetes import client, config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					OIDC_USERS_NAMESPACE = os.environ["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
									
								
							
							
						
						
									
										136
									
								
								app/slack.py
									
									
									
									
									
										Normal 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}…"
 | 
				
			||||||
@@ -1,5 +1,3 @@
 | 
				
			|||||||
version: '3.7'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
services:
 | 
					services:
 | 
				
			||||||
  mongoexpress:
 | 
					  mongoexpress:
 | 
				
			||||||
    image: mongo-express
 | 
					    image: mongo-express
 | 
				
			||||||
@@ -22,15 +20,15 @@ services:
 | 
				
			|||||||
      driver: none
 | 
					      driver: none
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  doorboy_proxy:
 | 
					  doorboy_proxy:
 | 
				
			||||||
    network_mode: host
 | 
					 | 
				
			||||||
    environment:
 | 
					    environment:
 | 
				
			||||||
      INVENTORY_API_KEY: "sptWL6XFxl4b8"
 | 
					      OIDC_USERS_NAMESPACE: passmower
 | 
				
			||||||
      DOORBOY_SECRET_FLOOR: "0123456789"
 | 
					      DOORBOY_SECRET_FLOOR: "0123456789"
 | 
				
			||||||
      DOORBOY_SECRET_WORKSHOP: "9999999999"
 | 
					      DOORBOY_SECRET_WORKSHOP: "9999999999"
 | 
				
			||||||
      DOORBOY_SECRET_OPEN_EVENTS: "1111111111"
 | 
					      SLACK_VERIFICATION_TOKEN: DEV
 | 
				
			||||||
      FLOOR_ACCESS_GROUP: "k-space:floor"
 | 
					      SLACK_DOORLOG_CALLBACK: DEV
 | 
				
			||||||
      WORKSHOP_ACCESS_GROUP: "k-space:workshop"
 | 
					      SLACK_CHANNEL_ID: CDL9H8Q9W
 | 
				
			||||||
      CARD_URI: "https://inventory-app-72zn4.codemowers.ee/cards"
 | 
					    env_file: .env
 | 
				
			||||||
      SWIPE_URI: "https://inventory-app-72zn4.codemowers.ee/m/doorboy/swipe"
 | 
					    ports:
 | 
				
			||||||
 | 
					     - "5000:5000"
 | 
				
			||||||
    build:
 | 
					    build:
 | 
				
			||||||
      context: .
 | 
					      context: .
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								requirements.txt
									
									
									
									
									
										Normal 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
 | 
				
			||||||
		Reference in New Issue
	
	Block a user