NODEDC_TASKMANAGER/plane-src/apps/api/plane/authentication/middleware/nodedc_access.py

189 lines
5.7 KiB
Python

import os
import time
import requests
from django.contrib.auth import logout
from django.http import HttpResponseRedirect, JsonResponse
from plane.db.models import ExternalIdentityLink, Session
OIDC_PROVIDER = "authentik"
class NodeDCAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self._enforce(request)
if response is not None:
return response
return self.get_response(request)
def _enforce(self, request):
config = get_access_config()
if not config["enabled"] or should_skip_path(request.path_info):
return None
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return None
link = ExternalIdentityLink.objects.filter(
provider=OIDC_PROVIDER,
user=user,
status=ExternalIdentityLink.Status.ACTIVE,
).first()
if link is None:
return deny_unlinked_user(request, config) if config["enforce_unlinked"] else None
cached = get_cached_access_decision(request, config["cache_seconds"])
if cached is not None:
return None if cached else revoke_session(request, user, "nodedc_access_revoked")
try:
decision = check_launcher_access(config, link, user)
except (ValueError, requests.RequestException):
return service_unavailable(request)
cache_access_decision(request, decision["allowed"], config["cache_seconds"])
if decision["groups"] is not None and decision["groups"] != link.groups:
link.groups = decision["groups"]
link.save(update_fields=["groups", "updated_at"])
if not decision["allowed"]:
return revoke_session(request, user, decision["reason"])
return None
def get_access_config():
check_url = os.environ.get("PLANE_NODEDC_ACCESS_CHECK_URL", "").strip()
token = (
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
)
return {
"enabled": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCEMENT", "0")) and bool(check_url and token),
"check_url": check_url,
"token": token,
"service_slug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager").strip() or "task-manager",
"timeout": float(os.environ.get("PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS", "3") or "3"),
"cache_seconds": max(0, int(os.environ.get("PLANE_NODEDC_ACCESS_CACHE_SECONDS", "0") or "0")),
"enforce_unlinked": is_truthy(os.environ.get("PLANE_NODEDC_ACCESS_ENFORCE_UNLINKED", "0")),
}
def check_launcher_access(config, link, user):
response = requests.post(
config["check_url"],
json={
"serviceSlug": config["service_slug"],
"subject": link.subject,
"email": link.email or user.email,
"userId": None,
},
headers={
"Authorization": f"Bearer {config['token']}",
"Accept": "application/json",
},
timeout=config["timeout"],
)
response.raise_for_status()
payload = response.json()
return {
"allowed": bool(payload.get("allowed")),
"reason": payload.get("reason") or "nodedc_access_denied",
"groups": payload.get("groups") if isinstance(payload.get("groups"), list) else None,
}
def get_cached_access_decision(request, cache_seconds):
if cache_seconds <= 0:
return None
checked_at = request.session.get("nodedc_access_checked_at")
allowed = request.session.get("nodedc_access_allowed")
if not checked_at or allowed is None:
return None
try:
checked_at_value = float(checked_at)
except (TypeError, ValueError):
return None
if time.time() - checked_at_value > cache_seconds:
return None
return bool(allowed)
def cache_access_decision(request, allowed, cache_seconds):
if cache_seconds <= 0:
request.session.pop("nodedc_access_checked_at", None)
request.session.pop("nodedc_access_allowed", None)
return
request.session["nodedc_access_checked_at"] = time.time()
request.session["nodedc_access_allowed"] = bool(allowed)
def revoke_session(request, user, reason):
Session.objects.filter(user_id=str(user.id)).delete()
logout(request)
if is_api_request(request):
status_code = 200 if request.path_info == "/api/users/session/" else 403
payload = {"is_authenticated": False} if status_code == 200 else {}
payload.update({"error": "nodedc_access_revoked", "reason": reason})
return JsonResponse(payload, status=status_code)
return HttpResponseRedirect(os.environ.get("PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL", "http://launcher.local.nodedc/"))
def deny_unlinked_user(request, config):
if not config["enforce_unlinked"]:
return None
return revoke_session(request, request.user, "nodedc_identity_not_linked")
def service_unavailable(request):
if is_api_request(request):
return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503)
return JsonResponse({"error": "nodedc_access_check_unavailable"}, status=503)
def should_skip_path(path):
return path.startswith(
(
"/auth/",
"/api/public/",
"/api/schema/",
"/static/",
"/assets/",
"/robots.txt",
)
)
def is_api_request(request):
return request.path_info.startswith("/api/")
def is_truthy(value):
return str(value).strip().lower() in {"1", "true", "yes", "on"}