From fbb05d874e3ca4255ff7c521f469c5c99aa10a98 Mon Sep 17 00:00:00 2001 From: DeOwl Date: Tue, 26 May 2026 11:54:10 +0300 Subject: [PATCH] v0.1.0 - working central server auth - added ci/cd --- .env.example | 4 + .gitea/workflows/build-and-push.yaml | 54 +++++ .gitignore | 2 + auth_backend/requirements.txt | 4 +- auth_backend/server.py | 301 ++++++++++++++++++++++++--- config/rabbitmq.conf | 5 +- docker-compose.yml | 5 +- rabbitmq.env | 0 8 files changed, 334 insertions(+), 41 deletions(-) create mode 100755 .env.example create mode 100644 .gitea/workflows/build-and-push.yaml create mode 100644 .gitignore delete mode 100755 rabbitmq.env diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..155f918 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +RABBITMQ_DEFAULT_USER=backend_admin +RABBITMQ_DEFAULT_PASS=your_strong_password + +BACKEND_URL=https://quantum-backend.example.com diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..3dbce6c --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,54 @@ +name: Build and Deploy Docker Image + +# Controls when the workflow will run. Here, it runs on every push to the 'main' branch. +on: + push: + branches: ["main"] + +# Environment variables used across the workflow +env: + # The URL of your Gitea instance (without http:// or https://) + GITEA_INSTANCE_URL: git.deowl.ru + # The full name of your image (e.g., 'myusername/myproject') + IMAGE_NAME: vkrb/rabbitmq-auth-backend + +jobs: + build-and-push: + # Runs the job on a runner with the 'ubuntu-latest' label. + runs-on: ubuntu-latest + # Optional but recommended: specifies the container image to use for the job. + # This ensures a consistent environment with Docker tools pre-installed. + container: + image: catthehacker/ubuntu:act-latest + + steps: + # 1. Check out your repository code so the workflow can access it. + - name: Checkout code + uses: actions/checkout@v4 + + # 2. Set up Docker Buildx, which is needed for building images. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 3. Log in to your Gitea instance's Container Registry. + # It uses secrets you must define in your repository settings. + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.GITEA_INSTANCE_URL }} + username: ${{ gitea.repository_owner }} + # Use a secret for the password/token. See Step 3 for setup. + password: ${{ secrets.REGISTRY_TOKEN }} + + # 4. Build the Docker image from your 'dockerfile_build' directory + # and push it to the Gitea registry. + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + # The path to the directory containing your Dockerfile + context: ./dockerfile_build + push: true + # Tag the image with the Gitea instance, image name, and the git commit SHA. + tags: | + ${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:latest + ${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c5156b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data +.env diff --git a/auth_backend/requirements.txt b/auth_backend/requirements.txt index 6d7503c..d05770f 100755 --- a/auth_backend/requirements.txt +++ b/auth_backend/requirements.txt @@ -1,3 +1 @@ -fastapi -uvicorn -python-multipart \ No newline at end of file +fastapi[all]==0.121.3 diff --git a/auth_backend/server.py b/auth_backend/server.py index f29d356..47625d1 100755 --- a/auth_backend/server.py +++ b/auth_backend/server.py @@ -1,46 +1,279 @@ -from fastapi import FastAPI, Request, Form -from fastapi.responses import PlainTextResponse +# rabbitmq_auth_backend.py +import json +import logging +import os +import re +import urllib.error +import urllib.request +from typing import Dict, List, Optional +from urllib.parse import urlencode -app = FastAPI() +from fastapi import FastAPI, Form, HTTPException +from pydantic import BaseModel +from starlette.responses import PlainTextResponse + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="RabbitMQ HTTP Auth Backend") + + +# Configuration +YOUR_API_BASE_URL = os.environ["BACKEND_URL"] + + +def get_default_headers(token: str = None, url: str = None) -> Dict[str, str]: + """ + Get default headers for API requests. + """ + headers = { + "Content-Type": "application/json", + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + + if token: + headers["Authorization"] = f"Bearer {token}" + + if url: + headers["Referer"] = url + # Extract host from URL + from urllib.parse import urlparse + + parsed_url = urlparse(url) + headers["Host"] = parsed_url.netloc + + return headers + + +async def validate_token_and_get_user(token: str) -> dict | None: + """ + Validate the Keycloak token and get user info. + This replicates what your get_current_token_payload does. + """ + try: + url = f"{YOUR_API_BASE_URL}/user" + headers = get_default_headers(token=token, url=url) + + # Call your API's /me endpoint to validate token + req = urllib.request.Request( + url, + headers=headers, + method="GET", + ) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + data = json.loads(response.read().decode()) + return data + else: + logger.error(f"Token validation failed: {response.status}") + return None + + except Exception as e: + logger.error(f"Error validating token: {e}") + return None + + +async def check_system_access(system_id: int, user_info: dict) -> bool: + """ + Check if the user has access to the computational system. + Uses your existing API endpoints. + """ + try: + params = urlencode({"system_id": system_id}) + url = f"{YOUR_API_BASE_URL}/machine/system?{params}" + headers = get_default_headers(token=user_info.get("token"), url=url) + + req = urllib.request.Request( + url, + headers=headers, + method="GET", + ) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + return True + elif response.status == 403: + logger.warning( + f"User {user_info.get('keycloak_id')} denied access to system {system_id}" + ) + return False + else: + logger.error(f"Error checking system access: {response.status}") + return False + + except urllib.error.HTTPError as e: + if e.code == 403: + logger.warning( + f"User {user_info.get('keycloak_id')} denied access to system {system_id}" + ) + return False + else: + logger.error(f"Error checking system access: {e.code}") + return False + except Exception as e: + logger.error(f"Error checking system access: {e}") + return False + + +async def get_system_teams(system_id: int) -> List[Dict]: + """ + Get all teams that this system is a member of. + """ + try: + url = f"{YOUR_API_BASE_URL}/machine/system/team?system_id={system_id}" + headers = get_default_headers(url=url) + + req = urllib.request.Request( + url, + headers=headers, + method="GET", + ) + + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + data = json.loads(response.read().decode()) + return data + else: + return [] + + except Exception as e: + logger.error(f"Error getting system teams: {e}") + return [] -USERS = { - "admin": {"password": "secret", "tags": ["administrator", "management"]}, - "user1": {"password": "password123", "tags": ["management"]}, -} @app.post("/rabbit/auth/user") -async def auth_user(username: str = Form(...), password: str = Form(...)): - user = USERS.get(username) - if user and user["password"] == password: - return PlainTextResponse("allow " + ", ".join(user["tags"])) - return PlainTextResponse("deny", status_code=403) +async def authenticate_user( + username: str = Form(...), # Changed from AuthRequest to form parameters + password: str = Form(...), +): + """ + RabbitMQ calls this to authenticate a user. + Username format: "system_{system_id}" + Password: Keycloak token + """ + logger.info(f"Auth request for username: {username}") + + try: + system_id = int(username) + except (IndexError, ValueError): + logger.warning(f"Invalid system_id in username: {username}") + return PlainTextResponse("deny") + + # Validate the token and get user info + user_info = await validate_token_and_get_user(password) + if not user_info: + logger.warning(f"Invalid token for system {system_id}") + return PlainTextResponse("deny") + + # Store token in user_info for subsequent checks + user_info["token"] = password + + # Check if user has access to the system + if not await check_system_access(system_id, user_info): + return PlainTextResponse("deny") + logger.info( + f"Authentication successful for system {system_id}, user {user_info.get('keycloak_id')}" + ) + + # Return success - RabbitMQ will allow connection + return PlainTextResponse("allow") + @app.post("/rabbit/auth/vhost") -async def auth_vhost(username: str = Form(...), vhost: str = Form(...), ip: str = Form(...)): - if username in USERS: - return PlainTextResponse("allow") - return PlainTextResponse("deny", status_code=403) +async def authorize_vhost( + username: str = Form(...), vhost: str = Form(...), ip: Optional[str] = Form(None) +): + """ + RabbitMQ calls this to check vhost permissions. + Only allow specific vhosts. + """ + logger.info(f"VHost check for user: {username}, vhost: {vhost}") + + # Only allow specific vhosts + allowed_vhosts = ["/"] + + if vhost not in allowed_vhosts: + logger.warning(f"Unauthorized vhost access attempt: {vhost}") + return PlainTextResponse("deny") + + logger.info(f"VHost access granted for {username} to {vhost}") + return PlainTextResponse("allow") + @app.post("/rabbit/auth/resource") -async def auth_resource(username: str = Form(...), vhost: str = Form(...), resource: str = Form(...), name: str = Form(...), permission: str = Form(...)): - if username == "admin": - return PlainTextResponse("allow") +async def authorize_resource( + username: str = Form(...), + vhost: str = Form(...), + resource: str = Form(...), + name: str = Form(...), + permission: str = Form(...), + ip: Optional[str] = Form(None), +): + """ + RabbitMQ calls this to check resource permissions. + """ + logger.info( + f"Resource check for {username} on vhost {vhost}, " + f"resource: {resource}, name: {name}, " + f"permission: {permission}" + ) - if username == "user1" and resource == "queue" and name.startswith("public_"): - if permission in ["read", "configure"]: - return PlainTextResponse("allow") + try: + system_id = int(username) + except (IndexError, ValueError): + return PlainTextResponse("deny") - return PlainTextResponse("deny", status_code=403) + # Permission logic based on vhost and resource type + if vhost == "/": + # On heartbeat vhost, allow publishing to "heartbeat" exchange + if resource == "exchange": + if (name == "heartbeat" or name == "progress_report") and ( + permission == "write" or permission == "configure" + ): + logger.info(f"Publish permission granted for heartbeat exchange") + return PlainTextResponse("allow") + else: + logger.warning(f"Unauthorized exchange access: {name}") -@app.post("/rabbit/auth/topic") -async def auth_topic(username: str = Form(...), - vhost: str = Form(...), - resource: str = Form(...), - name: str = Form(...), - permission: str = Form(...), - topic_path: str = Form(...), - ): + # Handle queue operations on team vhost + if resource == "queue": + # Pattern: team_{team_id}.qubits_{N} + team_queue_pattern = r"^team_(\w+)\.qubits_(\d+)$" + match = re.match(team_queue_pattern, name) - if username == "admin" or (username == "user1" and routing_key.startswith("logs.")): - return PlainTextResponse("allow") - return PlainTextResponse("deny", status_code=403) + if not match: + return PlainTextResponse("deny") + + team_id = match.group(1) + qubits = int(match.group(2)) + + # Get teams this system belongs to + system_teams = await get_system_teams(system_id) + logger.info(system_teams) + + team = list( + filter(lambda x: int(x.get("team_id")) == int(team_id), system_teams) + ) + + # Check if system is a member of this team + if len(team) == 0: + logger.warning(f"System {system_id} not a member of team {team_id}") + return PlainTextResponse("deny") + team = team[0] + + if team.get("qubits_given") < qubits: + logger.warning(f"System {system_id} does not have that many qubits") + return PlainTextResponse("deny") + # Allow configure and read permissions on team qubit queues + if permission in ["configure", "read"]: + logger.info(f"System {system_id} granted {permission} on queue {name}") + return PlainTextResponse("allow") + + return PlainTextResponse("deny") + + # Default deny + return PlainTextResponse("deny") diff --git a/config/rabbitmq.conf b/config/rabbitmq.conf index be81c60..6f28d8e 100755 --- a/config/rabbitmq.conf +++ b/config/rabbitmq.conf @@ -4,12 +4,13 @@ listeners.tcp.default = 5672 auth_backends.1 = http auth_backends.2 = internal + auth_http.http_method = post auth_http.user_path = http://rabbit-auth-server:8080/rabbit/auth/user auth_http.vhost_path = http://rabbit-auth-server:8080/rabbit/auth/vhost auth_http.resource_path = http://rabbit-auth-server:8080/rabbit/auth/resource -auth_http.topic_path = http://rabbit-auth-server:8080/rabbit/auth/topic + # Optional: timeout settings (milliseconds) auth_http.request_timeout = 5000 -auth_http.connection_timeout = 3000 \ No newline at end of file +auth_http.connection_timeout = 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 9a8d365..3b09f6d 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: image: rabbitmq:3-management container_name: rabbitmq restart: unless-stopped - env_file: "rabbitmq.env" + env_file: .env depends_on: - rabbit-auth-server volumes: @@ -15,8 +15,9 @@ services: - 5672:5672/tcp rabbit-auth-server: - build: ./auth_backend + image: git.deowl.ru/vkrb/rabbitmq-auth-backend:0.1.0 container_name: rabbit-auth-server + env_file: .env volumes: lib: diff --git a/rabbitmq.env b/rabbitmq.env deleted file mode 100755 index e69de29..0000000