188 lines
5.6 KiB
Python
188 lines
5.6 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()
|
|
)
|
|
|
|
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"}
|