Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/scripts/bump-version-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ set -e
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VERSION_FILE="${REPO_ROOT}/VERSION"
BRANCH="${GITHUB_REF_NAME:-main}"
SOURCE_BRANCH="next"

echo "On branch: $BRANCH"

cd "$REPO_ROOT"
Expand Down Expand Up @@ -35,3 +37,12 @@ fi
# push commit and tags
git push origin "$BRANCH"
git push origin --tags

# sync new version back into 'next' branch
echo "Syncing version $new_version back into $SOURCE_BRANCH..."
git checkout "$SOURCE_BRANCH"
git reset --hard "origin/$SOURCE_BRANCH"
git merge "$TARGET_BRANCH" -m "Chore: sync version $new_version back into $SOURCE_BRANCH"
git push origin "$SOURCE_BRANCH"

echo "Version successfully bumped to $new_version and synced to $SOURCE_BRANCH!"
5 changes: 2 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ repos:
rev: 25.1.0
hooks:
- id: black
language_version: python3.13
language_version: python3.14
args: ["--line-length=160"]

- repo: https://github.com/PyCQA/isort
rev: 6.0.1
hooks:
- id: isort
language_version: python3.13
language_version: python3.14
args: [ "--line-length", "160", "--profile", "black" ]

- repo: https://github.com/PyCQA/flake8
Expand All @@ -29,7 +29,6 @@ repos:
args:
- --config
- frontend/eslint.config.cjs
additional_dependencies: [eslint@9.17.0]

- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.0
Expand Down
Empty file.
10 changes: 10 additions & 0 deletions backend/rest_api/aide/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Apps.py for the OAuth application."""

from django.apps import AppConfig


class AideConfig(AppConfig):
"""Django AppConfig for the Aide application."""

default_auto_field = "django.db.models.BigAutoField"
name = "rest_api.aide"
27 changes: 27 additions & 0 deletions backend/rest_api/aide/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import asyncio
import logging

from rest_api.oauth.consumers import BaseAuthConsumer

_logger = logging.getLogger("aide")


class ChatSimulatorConsumer(BaseAuthConsumer):
"""
WebSocket consumer for handling chat messages and streaming responses.
"""

async def receive_json(self, content, **kwargs):
action = content.get("action")
if action == "send_message":
# send initial status
await self.send_json({"type": "status", "content": "Processing your message..."})
await asyncio.sleep(1)

# stream tokens back to frontend
tokens = ["Hello! ", "I ", "am ", "streaming ", "via ", "WebSockets!"]
for token in tokens:
await self.send_json({"type": "token", "content": token})
await asyncio.sleep(0.5)

await self.send_json({"type": "done"})
6 changes: 6 additions & 0 deletions backend/rest_api/aide/routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.urls import re_path
from rest_api.aide.consumers import ChatSimulatorConsumer

websocket_urlpatterns = [
re_path(r"^ws/aide/chat/$", ChatSimulatorConsumer.as_asgi()),
]
Empty file added backend/rest_api/aide/tests.py
Empty file.
1 change: 1 addition & 0 deletions backend/rest_api/aide/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
urlpatterns = []
Empty file added backend/rest_api/aide/views.py
Empty file.
16 changes: 14 additions & 2 deletions backend/rest_api/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.rest_api.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rest_api.settings")

application = get_asgi_application()
django_asgi_app = get_asgi_application()

import rest_api.routing as ws_routing # noqa: E402 - must import after setting DJANGO_SETTINGS_MODULE env var

application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter(ws_routing.websocket_urlpatterns))),
}
)
1 change: 1 addition & 0 deletions backend/rest_api/oauth/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TOKEN_NAME = "pandauitoken"
48 changes: 48 additions & 0 deletions backend/rest_api/oauth/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import logging

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from rest_api.oauth.constants import TOKEN_NAME
from rest_framework.authtoken.models import Token

_logger = logging.getLogger("oauth")


class BaseAuthConsumer(AsyncJsonWebsocketConsumer):
"""
Base consumer handling cookie/session authentication and origin verification
for all WebSocket endpoints.
"""

@database_sync_to_async
def get_user_from_cookie_token(self, token_key):
try:
return Token.objects.select_related("user").get(key=token_key).user
except (Token.DoesNotExist, Exception):
return AnonymousUser()

async def connect(self):
# get user from scope or cookie token
user = self.scope.get("user")
if not user or user.is_anonymous:
cookies = self.scope.get("cookies", {})
token_key = cookies.get(TOKEN_NAME)
if token_key:
user = await self.get_user_from_cookie_token(token_key)
self.scope["user"] = user

# reject unauthenticated connections
if not user or user.is_anonymous:
_logger.warning(f"Rejecting unauthorized WS connection to {self.scope['path']}")
await self.close(code=4001)
return

_logger.info(f"WS connected for user '{user.username}' at {self.scope['path']}")

# accept handshake
await self.accept()

async def disconnect(self, code):
user = self.scope.get("user", "Anonymous")
_logger.info(f"WS disconnected for user '{user}' (Code: {code}) at {self.scope['path']}")
38 changes: 34 additions & 4 deletions backend/rest_api/oauth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@

class GlobalPermission(BasePermission):
"""
Custom permission to check if the authenticated user is a member of the specified experiment.
Attribute-Based and Role-Based Access Control (ABAC/RBAC) permission class for DRF views.

Assumes that experiment membership is populated during OAuth token processing.
Authorization Logic:
1. Unauthenticated requests are rejected immediately (`False`).
2. Direct rule evaluation: Evaluates `(roles, obj_type, action, obj_dict, params)` against policy rules.
3. Implied read access: For `read` requests, users with `write` or `delete` permissions on the resource
are automatically granted `read` access.
4. Default-allow for read requests: If no explicit policy rule exists for the specified `(obj_type, 'read')`,
`read` access is allowed by default for authenticated users.
5. Default-deny for write/delete requests: Modification actions require explicit policy rules.
"""

@property
Expand All @@ -34,8 +41,26 @@ def get_user_roles(self, user) -> list[str]:
"""Helper method to extract user roles from the request.user object."""
return list(user.groups.values_list("name", flat=True))

def has_explicit_read_policy(self, obj_type: str) -> bool:
"""Returns True if any explicit policy rules exist for this object type on 'read'."""
if hasattr(self.authz, "enforcer"):
policies = self.authz.enforcer.get_filtered_policy(1, obj_type, "read")
return len(policies) > 0
return False

def has_permission(self, request, view):
"""Check if the user has permission based on their roles."""
"""
Determines whether the incoming request user is authorized to perform the requested action on a view.

Args:
request (Request): The incoming Django REST Framework request object.
view (APIView): The DRF view handling the request. Expected attributes:
- `object_type` (str, optional): Defaults to "unknown".
- `action` (str, optional): DRF view action (e.g., 'list', 'retrieve', 'create').

Returns:
bool: True if the request is permitted, False otherwise.
"""
if not request.user or not request.user.is_authenticated:
return False

Expand All @@ -58,10 +83,15 @@ def has_permission(self, request, view):
if self.authz.enforce(roles, obj_type, act, obj_dict, params):
return True

# if 'read' failed, check if they have 'write' or 'delete', because those should also allow 'read' access
if act == "read":
# if 'read' failed, check if they have 'write' or 'delete', because those should also allow 'read' access
for higher_act in ["write", "delete"]:
if self.authz.enforce(roles, obj_type, higher_act, obj_dict, params):
_logger.info("Granting read access as user have write or/and delete one")
return True
# allow read action by default unless a rule explicitly set in policy for this object type
if not self.has_explicit_read_policy(obj_type):
_logger.info(f"Granting default read access as no explicit rules exist for {obj_type}")
return True

return False
4 changes: 1 addition & 3 deletions backend/rest_api/oauth/policies/policy_atlas.csv
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
p,atlas-adc-pandamon,error_description,read,{},{},allow
p,atlas-adc-pandamon,error_description,write,{},{},allow
p,atlas-adc-pandamon,error_description,delete,{},{},allow
p,atlas,job,read,{},{},allow
p,atlas,task,read,{},{},allow
p,atlas-adc-pandamon,error_description,delete,{},{},allow
5 changes: 3 additions & 2 deletions backend/rest_api/oauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from django.contrib.auth.models import Group, User
from django.http import HttpRequest, HttpResponse
from rest_api.oauth.constants import TOKEN_NAME

_logger = logging.getLogger("oauth")

Expand All @@ -51,9 +52,9 @@ def preserve_cookies(request: HttpRequest, response: HttpResponse) -> HttpRespon
)

# Set the token in cookies
if request.user.is_authenticated and "pandauitoken" not in response.cookies:
if request.user.is_authenticated and TOKEN_NAME not in response.cookies:
response.set_cookie(
"pandauitoken",
TOKEN_NAME,
request.user.auth_token.key,
httponly=True,
secure=False,
Expand Down
6 changes: 6 additions & 0 deletions backend/rest_api/routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from rest_api.aide.routing import websocket_urlpatterns as aide_ws

# Combine all app websocket routes into a single list
websocket_urlpatterns = [
*aide_ws,
]
26 changes: 24 additions & 2 deletions backend/rest_api/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import os
from pathlib import Path

from django.utils.csp import CSP

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

Expand All @@ -23,6 +25,9 @@

# Application definition
INSTALLED_APPS = [
# websockets
"daphne",
"channels",
# django essentials
"django.contrib.auth",
"django.contrib.contenttypes",
Expand All @@ -39,10 +44,12 @@
"rest_api.oauth",
"rest_api.search",
"rest_api.task",
"rest_api.aide",
]

MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
Expand All @@ -52,6 +59,12 @@
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]

CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
Expand All @@ -69,6 +82,7 @@

ROOT_URLCONF = "rest_api.urls"

ASGI_APPLICATION = "rest_api.asgi.application"
WSGI_APPLICATION = "rest_api.wsgi.application"

# internationalization
Expand All @@ -77,8 +91,6 @@
USE_I18N = True
USE_TZ = False

# static files (CSS, JavaScript, Images)
STATIC_URL = "static/"

# UI frontend and backend URLs
FRONTEND_BASE_URL = os.getenv("PANDAUI_FRONTEND_BASE_URL", None)
Expand All @@ -89,3 +101,13 @@
PANDA_SERVER_API_URL = os.getenv("PANDA_SERVER_API_URL", None)
if not PANDA_SERVER_API_URL:
raise ValueError("PANDA_SERVER_API_URL environment variable is not set")


SECURE_CSP = {
"default-src": [CSP.SELF],
"connect-src": [
CSP.SELF,
"https:",
"wss:",
],
}
7 changes: 6 additions & 1 deletion backend/rest_api/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Development settings for the Django project.
"""

from .base import FRONTEND_BASE_URL, REST_FRAMEWORK
from .base import FRONTEND_BASE_URL, REST_FRAMEWORK, SECURE_CSP
from .logging import LOGGING

DEBUG = True
Expand All @@ -24,6 +24,11 @@
"x-csrftoken",
]

SECURE_CSP_DEBUG = True
SECURE_CSP["connect-src"].append(
f"wss://{FRONTEND_BASE_URL.replace('https://', '').replace('http://', '')}",
)

# Make auth work with HTTP in development
SOCIAL_AUTH_REDIRECT_IS_HTTPS = False
# Make session work with HTTP in development
Expand Down
5 changes: 3 additions & 2 deletions backend/rest_api/settings/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from .base import INSTALLED_APPS

LOG_LEVEL = os.getenv("PANDAUI_LOG_LEVEL", "INFO")
LOG_PATH = os.getenv("PANDAUI_LOG_PATH", "/tmp") + "/"
LOG_PATH = os.getenv("PANDAUI_LOG_PATH", "/tmp")
LOG_PATH = LOG_PATH if LOG_PATH.endswith("/") else LOG_PATH + "/"
LOG_MAX_BYTES = int(os.getenv("PANDAUI_LOG_MAX_BYTES", 100 * 1024 * 1024))

# base logging configuration
Expand Down Expand Up @@ -49,7 +50,7 @@
"general_error": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": f"{LOG_PATH}general_error.log",
"filename": f"{LOG_PATH}error.log",
"formatter": "verbose",
},
},
Expand Down
Loading
Loading