From c874946ed484a81894a03cc61d99cfa1a4745c2c Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 27 Jan 2026 23:49:19 +0300 Subject: [PATCH] tested --- backend/src/api/auth.py | 12 +- backend/src/api/routes/admin.py | 6 +- backend/src/api/routes/environments.py | 16 +- backend/src/api/routes/git.py | 99 +++++++++-- backend/src/api/routes/mappings.py | 16 +- backend/src/api/routes/migration.py | 15 +- backend/src/api/routes/plugins.py | 5 +- backend/src/api/routes/settings.py | 12 +- backend/src/api/routes/storage.py | 23 ++- backend/src/api/routes/tasks.py | 18 +- backend/src/core/auth/oauth.py | 10 ++ backend/src/dependencies.py | 2 +- backend/src/models/auth.py | 2 +- backend/src/plugins/migration.py | 4 +- backend/src/scripts/seed_permissions.py | 39 ++++- backend/src/services/auth_service.py | 2 +- backend/src/services/mapping_service.py | 6 +- backend/tests/test_auth.py | 162 ++++++++++++++++++ frontend/src/components/TaskHistory.svelte | 12 +- .../src/components/tools/DebugTool.svelte | 4 +- frontend/src/lib/api.js | 22 ++- frontend/src/routes/admin/users/+page.svelte | 15 +- frontend/src/routes/git/+page.svelte | 9 +- frontend/src/routes/migration/+page.svelte | 90 ++++------ .../routes/migration/mappings/+page.svelte | 50 ++---- frontend/src/services/connectionService.js | 32 +--- frontend/src/services/gitService.js | 125 ++++---------- frontend/src/services/taskService.js | 52 +----- frontend/src/services/toolsService.js | 24 +-- specs/016-multi-user-auth/tasks.md | 10 +- 30 files changed, 538 insertions(+), 356 deletions(-) create mode 100644 backend/tests/test_auth.py diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 6ae24f59..014cb992 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -16,7 +16,7 @@ from ..core.database import get_auth_db from ..services.auth_service import AuthService from ..schemas.auth import Token, User as UserSchema from ..dependencies import get_current_user -from ..core.auth.oauth import oauth +from ..core.auth.oauth import oauth, is_adfs_configured from ..core.auth.logger import log_security_event from ..core.logger import belief_scope import starlette.requests @@ -85,6 +85,11 @@ async def logout(current_user: UserSchema = Depends(get_current_user)): @router.get("/login/adfs") async def login_adfs(request: starlette.requests.Request): with belief_scope("api.auth.login_adfs"): + if not is_adfs_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables." + ) redirect_uri = request.url_for('auth_callback_adfs') return await oauth.adfs.authorize_redirect(request, str(redirect_uri)) # [/DEF:login_adfs:Function] @@ -95,6 +100,11 @@ async def login_adfs(request: starlette.requests.Request): @router.get("/callback/adfs", name="auth_callback_adfs") async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)): with belief_scope("api.auth.callback_adfs"): + if not is_adfs_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables." + ) token = await oauth.adfs.authorize_access_token(request) user_info = token.get('userinfo') if not user_info: diff --git a/backend/src/api/routes/admin.py b/backend/src/api/routes/admin.py index c623434b..78482a82 100644 --- a/backend/src/api/routes/admin.py +++ b/backend/src/api/routes/admin.py @@ -22,7 +22,7 @@ from ...schemas.auth import ( ) from ...models.auth import User, Role, Permission, ADGroupMapping from ...dependencies import has_permission, get_current_user -from ...core.logger import belief_scope +from ...core.logger import logger, belief_scope # [/SECTION] # [DEF:router:Variable] @@ -126,13 +126,17 @@ async def delete_user( _ = Depends(has_permission("admin:users", "WRITE")) ): with belief_scope("api.admin.delete_user"): + logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}") repo = AuthRepository(db) user = repo.get_user_by_id(user_id) if not user: + logger.warning(f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}") raise HTTPException(status_code=404, detail="User not found") + logger.info(f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}") db.delete(user) db.commit() + logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}") return None # [/DEF:delete_user:Function] diff --git a/backend/src/api/routes/environments.py b/backend/src/api/routes/environments.py index cff6847c..7cfbb580 100644 --- a/backend/src/api/routes/environments.py +++ b/backend/src/api/routes/environments.py @@ -11,7 +11,7 @@ # [SECTION: IMPORTS] from fastapi import APIRouter, Depends, HTTPException from typing import List, Dict, Optional -from ...dependencies import get_config_manager, get_scheduler_service +from ...dependencies import get_config_manager, get_scheduler_service, has_permission from ...core.superset_client import SupersetClient from pydantic import BaseModel, Field from ...core.config_models import Environment as EnvModel @@ -47,7 +47,10 @@ class DatabaseResponse(BaseModel): # @POST: Returns a list of EnvironmentResponse objects. # @RETURN: List[EnvironmentResponse] @router.get("", response_model=List[EnvironmentResponse]) -async def get_environments(config_manager=Depends(get_config_manager)): +async def get_environments( + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("environments", "READ")) +): with belief_scope("get_environments"): envs = config_manager.get_environments() # Ensure envs is a list @@ -77,7 +80,8 @@ async def update_environment_schedule( id: str, schedule: ScheduleSchema, config_manager=Depends(get_config_manager), - scheduler_service=Depends(get_scheduler_service) + scheduler_service=Depends(get_scheduler_service), + _ = Depends(has_permission("admin:settings", "WRITE")) ): with belief_scope("update_environment_schedule", f"id={id}"): envs = config_manager.get_environments() @@ -104,7 +108,11 @@ async def update_environment_schedule( # @PARAM: id (str) - The environment ID. # @RETURN: List[Dict] - List of databases. @router.get("/{id}/databases") -async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)): +async def get_environment_databases( + id: str, + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("admin:settings", "READ")) +): with belief_scope("get_environment_databases", f"id={id}"): envs = config_manager.get_environments() env = next((e for e in envs if e.id == id), None) diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index 361e4510..f04fbf45 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List, Optional import typing -from src.dependencies import get_config_manager +from src.dependencies import get_config_manager, has_permission from src.core.database import get_db from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository from src.api.routes.git_schemas import ( @@ -34,7 +34,10 @@ git_service = GitService() # @POST: Returns a list of all GitServerConfig objects from the database. # @RETURN: List[GitServerConfigSchema] @router.get("/config", response_model=List[GitServerConfigSchema]) -async def get_git_configs(db: Session = Depends(get_db)): +async def get_git_configs( + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "READ")) +): with belief_scope("get_git_configs"): return db.query(GitServerConfig).all() # [/DEF:get_git_configs:Function] @@ -46,7 +49,11 @@ async def get_git_configs(db: Session = Depends(get_db)): # @PARAM: config (GitServerConfigCreate) # @RETURN: GitServerConfigSchema @router.post("/config", response_model=GitServerConfigSchema) -async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)): +async def create_git_config( + config: GitServerConfigCreate, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): with belief_scope("create_git_config"): db_config = GitServerConfig(**config.dict()) db.add(db_config) @@ -61,7 +68,11 @@ async def create_git_config(config: GitServerConfigCreate, db: Session = Depends # @POST: The configuration record is removed from the database. # @PARAM: config_id (str) @router.delete("/config/{config_id}") -async def delete_git_config(config_id: str, db: Session = Depends(get_db)): +async def delete_git_config( + config_id: str, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): with belief_scope("delete_git_config"): db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first() if not db_config: @@ -78,7 +89,10 @@ async def delete_git_config(config_id: str, db: Session = Depends(get_db)): # @POST: Returns success if the connection is validated via GitService. # @PARAM: config (GitServerConfigCreate) @router.post("/config/test") -async def test_git_config(config: GitServerConfigCreate): +async def test_git_config( + config: GitServerConfigCreate, + _ = Depends(has_permission("admin:settings", "READ")) +): with belief_scope("test_git_config"): success = await git_service.test_connection(config.provider, config.url, config.pat) if success: @@ -94,7 +108,12 @@ async def test_git_config(config: GitServerConfigCreate): # @PARAM: dashboard_id (int) # @PARAM: init_data (RepoInitRequest) @router.post("/repositories/{dashboard_id}/init") -async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)): +async def init_repository( + dashboard_id: int, + init_data: RepoInitRequest, + db: Session = Depends(get_db), + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("init_repository"): # 1. Get config config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first() @@ -138,7 +157,10 @@ async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Ses # @PARAM: dashboard_id (int) # @RETURN: List[BranchSchema] @router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema]) -async def get_branches(dashboard_id: int): +async def get_branches( + dashboard_id: int, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("get_branches"): try: return git_service.list_branches(dashboard_id) @@ -153,7 +175,11 @@ async def get_branches(dashboard_id: int): # @PARAM: dashboard_id (int) # @PARAM: branch_data (BranchCreate) @router.post("/repositories/{dashboard_id}/branches") -async def create_branch(dashboard_id: int, branch_data: BranchCreate): +async def create_branch( + dashboard_id: int, + branch_data: BranchCreate, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("create_branch"): try: git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch) @@ -169,7 +195,11 @@ async def create_branch(dashboard_id: int, branch_data: BranchCreate): # @PARAM: dashboard_id (int) # @PARAM: checkout_data (BranchCheckout) @router.post("/repositories/{dashboard_id}/checkout") -async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout): +async def checkout_branch( + dashboard_id: int, + checkout_data: BranchCheckout, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("checkout_branch"): try: git_service.checkout_branch(dashboard_id, checkout_data.name) @@ -185,7 +215,11 @@ async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout): # @PARAM: dashboard_id (int) # @PARAM: commit_data (CommitCreate) @router.post("/repositories/{dashboard_id}/commit") -async def commit_changes(dashboard_id: int, commit_data: CommitCreate): +async def commit_changes( + dashboard_id: int, + commit_data: CommitCreate, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("commit_changes"): try: git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files) @@ -200,7 +234,10 @@ async def commit_changes(dashboard_id: int, commit_data: CommitCreate): # @POST: Local commits are pushed to the remote repository. # @PARAM: dashboard_id (int) @router.post("/repositories/{dashboard_id}/push") -async def push_changes(dashboard_id: int): +async def push_changes( + dashboard_id: int, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("push_changes"): try: git_service.push_changes(dashboard_id) @@ -215,7 +252,10 @@ async def push_changes(dashboard_id: int): # @POST: Remote changes are fetched and merged into the local branch. # @PARAM: dashboard_id (int) @router.post("/repositories/{dashboard_id}/pull") -async def pull_changes(dashboard_id: int): +async def pull_changes( + dashboard_id: int, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("pull_changes"): try: git_service.pull_changes(dashboard_id) @@ -231,7 +271,11 @@ async def pull_changes(dashboard_id: int): # @PARAM: dashboard_id (int) # @PARAM: source_env_id (Optional[str]) @router.post("/repositories/{dashboard_id}/sync") -async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None): +async def sync_dashboard( + dashboard_id: int, + source_env_id: typing.Optional[str] = None, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("sync_dashboard"): try: from src.plugins.git_plugin import GitPlugin @@ -251,7 +295,10 @@ async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] # @POST: Returns a list of DeploymentEnvironmentSchema objects. # @RETURN: List[DeploymentEnvironmentSchema] @router.get("/environments", response_model=List[DeploymentEnvironmentSchema]) -async def get_environments(config_manager=Depends(get_config_manager)): +async def get_environments( + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("environments", "READ")) +): with belief_scope("get_environments"): envs = config_manager.get_environments() return [ @@ -271,7 +318,11 @@ async def get_environments(config_manager=Depends(get_config_manager)): # @PARAM: dashboard_id (int) # @PARAM: deploy_data (DeployRequest) @router.post("/repositories/{dashboard_id}/deploy") -async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest): +async def deploy_dashboard( + dashboard_id: int, + deploy_data: DeployRequest, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("deploy_dashboard"): try: from src.plugins.git_plugin import GitPlugin @@ -293,7 +344,11 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest): # @PARAM: limit (int) # @RETURN: List[CommitSchema] @router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema]) -async def get_history(dashboard_id: int, limit: int = 50): +async def get_history( + dashboard_id: int, + limit: int = 50, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("get_history"): try: return git_service.get_commit_history(dashboard_id, limit) @@ -308,7 +363,10 @@ async def get_history(dashboard_id: int, limit: int = 50): # @PARAM: dashboard_id (int) # @RETURN: dict @router.get("/repositories/{dashboard_id}/status") -async def get_repository_status(dashboard_id: int): +async def get_repository_status( + dashboard_id: int, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("get_repository_status"): try: return git_service.get_status(dashboard_id) @@ -325,7 +383,12 @@ async def get_repository_status(dashboard_id: int): # @PARAM: staged (bool) # @RETURN: str @router.get("/repositories/{dashboard_id}/diff") -async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False): +async def get_repository_diff( + dashboard_id: int, + file_path: Optional[str] = None, + staged: bool = False, + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): with belief_scope("get_repository_diff"): try: diff_text = git_service.get_diff(dashboard_id, file_path, staged) diff --git a/backend/src/api/routes/mappings.py b/backend/src/api/routes/mappings.py index 60b10c05..03479011 100644 --- a/backend/src/api/routes/mappings.py +++ b/backend/src/api/routes/mappings.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List, Optional from ...core.logger import belief_scope -from ...dependencies import get_config_manager +from ...dependencies import get_config_manager, has_permission from ...core.database import get_db from ...models.mapping import DatabaseMapping from pydantic import BaseModel @@ -60,7 +60,8 @@ class SuggestRequest(BaseModel): async def get_mappings( source_env_id: Optional[str] = None, target_env_id: Optional[str] = None, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + _ = Depends(has_permission("plugin:mapper", "EXECUTE")) ): with belief_scope("get_mappings"): query = db.query(DatabaseMapping) @@ -76,7 +77,11 @@ async def get_mappings( # @PRE: mapping is valid MappingCreate, db session is injected. # @POST: DatabaseMapping created or updated in database. @router.post("", response_model=MappingResponse) -async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)): +async def create_mapping( + mapping: MappingCreate, + db: Session = Depends(get_db), + _ = Depends(has_permission("plugin:mapper", "EXECUTE")) +): with belief_scope("create_mapping"): # Check if mapping already exists existing = db.query(DatabaseMapping).filter( @@ -106,10 +111,11 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)): @router.post("/suggest") async def suggest_mappings_api( request: SuggestRequest, - config_manager=Depends(get_config_manager) + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("plugin:mapper", "EXECUTE")) ): with belief_scope("suggest_mappings_api"): - from backend.src.services.mapping_service import MappingService + from ...services.mapping_service import MappingService service = MappingService(config_manager) try: return await service.get_suggestions(request.source_env_id, request.target_env_id) diff --git a/backend/src/api/routes/migration.py b/backend/src/api/routes/migration.py index 12e050ca..99d28d24 100644 --- a/backend/src/api/routes/migration.py +++ b/backend/src/api/routes/migration.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException from typing import List, Dict -from ...dependencies import get_config_manager, get_task_manager +from ...dependencies import get_config_manager, get_task_manager, has_permission from ...models.dashboard import DashboardMetadata, DashboardSelection from ...core.superset_client import SupersetClient from ...core.logger import belief_scope @@ -21,7 +21,11 @@ router = APIRouter(prefix="/api", tags=["migration"]) # @PARAM: env_id (str) - The ID of the environment to fetch from. # @RETURN: List[DashboardMetadata] @router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata]) -async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)): +async def get_dashboards( + env_id: str, + config_manager=Depends(get_config_manager), + _ = Depends(has_permission("plugin:migration", "EXECUTE")) +): with belief_scope("get_dashboards", f"env_id={env_id}"): environments = config_manager.get_environments() env = next((e for e in environments if e.id == env_id), None) @@ -40,7 +44,12 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager) # @PARAM: selection (DashboardSelection) - The dashboards to migrate. # @RETURN: Dict - {"task_id": str, "message": str} @router.post("/migration/execute") -async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)): +async def execute_migration( + selection: DashboardSelection, + config_manager=Depends(get_config_manager), + task_manager=Depends(get_task_manager), + _ = Depends(has_permission("plugin:migration", "EXECUTE")) +): with belief_scope("execute_migration"): # Validate environments exist environments = config_manager.get_environments() diff --git a/backend/src/api/routes/plugins.py b/backend/src/api/routes/plugins.py index ac5f14af..a9005ac9 100755 --- a/backend/src/api/routes/plugins.py +++ b/backend/src/api/routes/plugins.py @@ -7,7 +7,7 @@ from typing import List from fastapi import APIRouter, Depends from ...core.plugin_base import PluginConfig -from ...dependencies import get_plugin_loader +from ...dependencies import get_plugin_loader, has_permission from ...core.logger import belief_scope router = APIRouter() @@ -19,7 +19,8 @@ router = APIRouter() # @RETURN: List[PluginConfig] - List of registered plugins. @router.get("", response_model=List[PluginConfig]) async def list_plugins( - plugin_loader = Depends(get_plugin_loader) + plugin_loader = Depends(get_plugin_loader), + _ = Depends(has_permission("plugins", "READ")) ): with belief_scope("list_plugins"): """ diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 6d7d751b..e32464fb 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -31,7 +31,7 @@ router = APIRouter() @router.get("", response_model=AppConfig) async def get_settings( config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "READ")) + _ = Depends(has_permission("admin:settings", "READ")) ): with belief_scope("get_settings"): logger.info("[get_settings][Entry] Fetching all settings") @@ -53,7 +53,7 @@ async def get_settings( async def update_global_settings( settings: GlobalSettings, config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "WRITE")) + _ = Depends(has_permission("admin:settings", "WRITE")) ): with belief_scope("update_global_settings"): logger.info("[update_global_settings][Entry] Updating global settings") @@ -68,7 +68,7 @@ async def update_global_settings( @router.get("/storage", response_model=StorageConfig) async def get_storage_settings( config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "READ")) + _ = Depends(has_permission("admin:settings", "READ")) ): with belief_scope("get_storage_settings"): return config_manager.get_config().settings.storage @@ -83,7 +83,7 @@ async def get_storage_settings( async def update_storage_settings( storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "WRITE")) + _ = Depends(has_permission("admin:settings", "WRITE")) ): with belief_scope("update_storage_settings"): is_valid, message = config_manager.validate_path(storage.root_path) @@ -104,7 +104,7 @@ async def update_storage_settings( @router.get("/environments", response_model=List[Environment]) async def get_environments( config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "READ")) + _ = Depends(has_permission("admin:settings", "READ")) ): with belief_scope("get_environments"): logger.info("[get_environments][Entry] Fetching environments") @@ -121,7 +121,7 @@ async def get_environments( async def add_environment( env: Environment, config_manager: ConfigManager = Depends(get_config_manager), - _ = Depends(has_permission("settings", "WRITE")) + _ = Depends(has_permission("admin:settings", "WRITE")) ): with belief_scope("add_environment"): logger.info(f"[add_environment][Entry] Adding environment {env.id}") diff --git a/backend/src/api/routes/storage.py b/backend/src/api/routes/storage.py index cebea8fc..10f9e776 100644 --- a/backend/src/api/routes/storage.py +++ b/backend/src/api/routes/storage.py @@ -8,11 +8,12 @@ # @INVARIANT: All paths must be validated against path traversal. # [SECTION: IMPORTS] +from pathlib import Path from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from fastapi.responses import FileResponse from typing import List, Optional from ...models.storage import StoredFile, FileCategory -from ...dependencies import get_plugin_loader +from ...dependencies import get_plugin_loader, has_permission from ...plugins.storage.plugin import StoragePlugin from ...core.logger import belief_scope # [/SECTION] @@ -34,7 +35,8 @@ router = APIRouter(tags=["storage"]) async def list_files( category: Optional[FileCategory] = None, path: Optional[str] = None, - plugin_loader=Depends(get_plugin_loader) + plugin_loader=Depends(get_plugin_loader), + _ = Depends(has_permission("plugin:storage", "READ")) ): with belief_scope("list_files"): storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") @@ -63,7 +65,8 @@ async def upload_file( category: FileCategory = Form(...), path: Optional[str] = Form(None), file: UploadFile = File(...), - plugin_loader=Depends(get_plugin_loader) + plugin_loader=Depends(get_plugin_loader), + _ = Depends(has_permission("plugin:storage", "WRITE")) ): with belief_scope("upload_file"): storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") @@ -89,7 +92,12 @@ async def upload_file( # # @RELATION: CALLS -> StoragePlugin.delete_file @router.delete("/files/{category}/{path:path}", status_code=204) -async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)): +async def delete_file( + category: FileCategory, + path: str, + plugin_loader=Depends(get_plugin_loader), + _ = Depends(has_permission("plugin:storage", "WRITE")) +): with belief_scope("delete_file"): storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") if not storage_plugin: @@ -114,7 +122,12 @@ async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(g # # @RELATION: CALLS -> StoragePlugin.get_file_path @router.get("/download/{category}/{path:path}") -async def download_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)): +async def download_file( + category: FileCategory, + path: str, + plugin_loader=Depends(get_plugin_loader), + _ = Depends(has_permission("plugin:storage", "READ")) +): with belief_scope("download_file"): storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") if not storage_plugin: diff --git a/backend/src/api/routes/tasks.py b/backend/src/api/routes/tasks.py index a28096f6..4aa61a17 100755 --- a/backend/src/api/routes/tasks.py +++ b/backend/src/api/routes/tasks.py @@ -64,7 +64,8 @@ async def list_tasks( limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "READ")) ): """ Retrieve a list of tasks with pagination and optional status filter. @@ -83,7 +84,8 @@ async def list_tasks( # @RETURN: Task - The task details. async def get_task( task_id: str, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "READ")) ): """ Retrieve the details of a specific task. @@ -105,7 +107,8 @@ async def get_task( # @RETURN: List[LogEntry] - List of log entries. async def get_task_logs( task_id: str, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "READ")) ): """ Retrieve logs for a specific task. @@ -129,7 +132,8 @@ async def get_task_logs( async def resolve_task( task_id: str, request: ResolveTaskRequest, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "WRITE")) ): """ Resolve a task that is awaiting mapping. @@ -154,7 +158,8 @@ async def resolve_task( async def resume_task( task_id: str, request: ResumeTaskRequest, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "WRITE")) ): """ Resume a task that is awaiting input (e.g., passwords). @@ -176,7 +181,8 @@ async def resume_task( # @POST: Tasks are removed from memory/persistence. async def clear_tasks( status: Optional[TaskStatus] = None, - task_manager: TaskManager = Depends(get_task_manager) + task_manager: TaskManager = Depends(get_task_manager), + _ = Depends(has_permission("tasks", "WRITE")) ): """ Clear tasks matching the status filter. If no filter, clears all non-running tasks. diff --git a/backend/src/core/auth/oauth.py b/backend/src/core/auth/oauth.py index 851a5179..4a99c7e0 100644 --- a/backend/src/core/auth/oauth.py +++ b/backend/src/core/auth/oauth.py @@ -35,6 +35,16 @@ def register_adfs(): ) # [/DEF:register_adfs:Function] +# [DEF:is_adfs_configured:Function] +# @PURPOSE: Checks if ADFS is properly configured. +# @PRE: None. +# @POST: Returns True if ADFS client is registered, False otherwise. +# @RETURN: bool - Configuration status. +def is_adfs_configured() -> bool: + """Check if ADFS OAuth client is registered.""" + return 'adfs' in oauth._registry +# [/DEF:is_adfs_configured:Function] + # Initial registration register_adfs() diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index bc206a0e..92c59665 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -86,7 +86,7 @@ def get_scheduler_service() -> SchedulerService: # [DEF:oauth2_scheme:Variable] # @PURPOSE: OAuth2 password bearer scheme for token extraction. -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") # [/DEF:oauth2_scheme:Variable] # [DEF:get_current_user:Function] diff --git a/backend/src/models/auth.py b/backend/src/models/auth.py index fbbc736e..62e3179e 100644 --- a/backend/src/models/auth.py +++ b/backend/src/models/auth.py @@ -95,7 +95,7 @@ class ADGroupMapping(Base): __tablename__ = "ad_group_mappings" id = Column(String, primary_key=True, default=generate_uuid) - ad_group_name = Column(String, unique=True, index=True, nullable=False) + ad_group = Column(String, unique=True, index=True, nullable=False) role_id = Column(String, ForeignKey("roles.id"), nullable=False) role = relationship("Role") diff --git a/backend/src/plugins/migration.py b/backend/src/plugins/migration.py index 6270d8e8..44df166c 100755 --- a/backend/src/plugins/migration.py +++ b/backend/src/plugins/migration.py @@ -303,9 +303,9 @@ class MigrationPlugin(PluginBase): try: exported_content, _ = from_c.export_dashboard(dash_id) - with create_temp_file(content=exported_content, dry_run=True, suffix=".zip", logger=logger) as tmp_zip_path: + with create_temp_file(content=exported_content, dry_run=True, suffix=".zip") as tmp_zip_path: # Always transform to strip databases to avoid password errors - with create_temp_file(suffix=".zip", dry_run=True, logger=logger) as tmp_new_zip: + with create_temp_file(suffix=".zip", dry_run=True) as tmp_new_zip: success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False) if not success and replace_db_config: diff --git a/backend/src/scripts/seed_permissions.py b/backend/src/scripts/seed_permissions.py index e9ece8ed..378b829f 100644 --- a/backend/src/scripts/seed_permissions.py +++ b/backend/src/scripts/seed_permissions.py @@ -16,7 +16,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).parent.parent.parent)) from src.core.database import AuthSessionLocal -from src.models.auth import Permission +from src.models.auth import Permission, Role +from src.core.auth.repository import AuthRepository from src.core.logger import logger, belief_scope # [/SECTION] @@ -29,6 +30,10 @@ INITIAL_PERMISSIONS = [ {"resource": "admin:roles", "action": "WRITE"}, {"resource": "admin:settings", "action": "READ"}, {"resource": "admin:settings", "action": "WRITE"}, + {"resource": "environments", "action": "READ"}, + {"resource": "plugins", "action": "READ"}, + {"resource": "tasks", "action": "READ"}, + {"resource": "tasks", "action": "WRITE"}, # Plugin Permissions {"resource": "plugin:backup", "action": "EXECUTE"}, @@ -37,6 +42,8 @@ INITIAL_PERMISSIONS = [ {"resource": "plugin:search", "action": "EXECUTE"}, {"resource": "plugin:git", "action": "EXECUTE"}, {"resource": "plugin:storage", "action": "EXECUTE"}, + {"resource": "plugin:storage", "action": "READ"}, + {"resource": "plugin:storage", "action": "WRITE"}, {"resource": "plugin:debug", "action": "EXECUTE"}, ] # [/DEF:INITIAL_PERMISSIONS:Constant] @@ -66,6 +73,36 @@ def seed_permissions(): db.commit() logger.info(f"Seeding completed. Added {count} new permissions.") + + # Assign permissions to User role + repo = AuthRepository(db) + user_role = repo.get_role_by_name("User") + if not user_role: + user_role = Role(name="User", description="Standard user with plugin access") + db.add(user_role) + db.flush() + + user_permissions = [ + ("plugin:mapper", "EXECUTE"), + ("plugin:migration", "EXECUTE"), + ("plugin:backup", "EXECUTE"), + ("plugin:git", "EXECUTE"), + ("plugin:storage", "READ"), + ("plugin:storage", "WRITE"), + ("environments", "READ"), + ("plugins", "READ"), + ("tasks", "READ"), + ("tasks", "WRITE"), + ] + + for res, act in user_permissions: + perm = repo.get_permission_by_resource_action(res, act) + if perm and perm not in user_role.permissions: + user_role.permissions.append(perm) + + db.commit() + logger.info("User role permissions updated.") + except Exception as e: logger.error(f"Failed to seed permissions: {e}") db.rollback() diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index a25e479c..e577d405 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -101,7 +101,7 @@ class AuthService: # Update roles based on group mappings from ..models.auth import ADGroupMapping mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter( - ADGroupMapping.ad_group_name.in_(ad_groups) + ADGroupMapping.ad_group.in_(ad_groups) ).all() user.roles = mapped_roles diff --git a/backend/src/services/mapping_service.py b/backend/src/services/mapping_service.py index 12a9642f..67341f72 100644 --- a/backend/src/services/mapping_service.py +++ b/backend/src/services/mapping_service.py @@ -10,9 +10,9 @@ # [SECTION: IMPORTS] from typing import List, Dict -from backend.src.core.logger import belief_scope -from backend.src.core.superset_client import SupersetClient -from backend.src.core.utils.matching import suggest_mappings +from ..core.logger import belief_scope +from ..core.superset_client import SupersetClient +from ..core.utils.matching import suggest_mappings # [/SECTION] # [DEF:MappingService:Class] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 00000000..1a1de892 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,162 @@ +import sys +import os +from pathlib import Path + +# Add src to path +sys.path.append(str(Path(__file__).parent.parent / "src")) + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from src.core.database import Base, get_auth_db +from src.models.auth import User, Role, Permission, ADGroupMapping +from src.services.auth_service import AuthService +from src.core.auth.repository import AuthRepository +from src.core.auth.security import verify_password, get_password_hash + +# Create in-memory SQLite database for testing +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create all tables +Base.metadata.create_all(bind=engine) + +@pytest.fixture +def db_session(): + """Create a new database session with a transaction, rollback after test""" + connection = engine.connect() + transaction = connection.begin() + session = TestingSessionLocal(bind=connection) + + yield session + + session.close() + transaction.rollback() + connection.close() + +@pytest.fixture +def auth_service(db_session): + return AuthService(db_session) + +@pytest.fixture +def auth_repo(db_session): + return AuthRepository(db_session) + +def test_create_user(auth_repo): + """Test user creation""" + user = User( + username="testuser", + email="test@example.com", + password_hash=get_password_hash("testpassword123"), + auth_source="LOCAL" + ) + + auth_repo.db.add(user) + auth_repo.db.commit() + + retrieved_user = auth_repo.get_user_by_username("testuser") + assert retrieved_user is not None + assert retrieved_user.username == "testuser" + assert retrieved_user.email == "test@example.com" + assert verify_password("testpassword123", retrieved_user.password_hash) + +def test_authenticate_user(auth_service, auth_repo): + """Test user authentication with valid and invalid credentials""" + user = User( + username="testuser", + email="test@example.com", + password_hash=get_password_hash("testpassword123"), + auth_source="LOCAL" + ) + + auth_repo.db.add(user) + auth_repo.db.commit() + + # Test valid credentials + authenticated_user = auth_service.authenticate_user("testuser", "testpassword123") + assert authenticated_user is not None + assert authenticated_user.username == "testuser" + + # Test invalid password + invalid_user = auth_service.authenticate_user("testuser", "wrongpassword") + assert invalid_user is None + + # Test invalid username + invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123") + assert invalid_user is None + +def test_create_session(auth_service, auth_repo): + """Test session token creation""" + user = User( + username="testuser", + email="test@example.com", + password_hash=get_password_hash("testpassword123"), + auth_source="LOCAL" + ) + + auth_repo.db.add(user) + auth_repo.db.commit() + + session = auth_service.create_session(user) + assert "access_token" in session + assert "token_type" in session + assert session["token_type"] == "bearer" + assert len(session["access_token"]) > 0 + +def test_role_permission_association(auth_repo): + """Test role and permission association""" + role = Role(name="Admin", description="System administrator") + perm1 = Permission(resource="admin:users", action="READ") + perm2 = Permission(resource="admin:users", action="WRITE") + + role.permissions.extend([perm1, perm2]) + + auth_repo.db.add(role) + auth_repo.db.commit() + + retrieved_role = auth_repo.get_role_by_name("Admin") + assert retrieved_role is not None + assert len(retrieved_role.permissions) == 2 + + permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions] + assert "admin:users:READ" in permissions + assert "admin:users:WRITE" in permissions + +def test_user_role_association(auth_repo): + """Test user and role association""" + role = Role(name="Admin", description="System administrator") + user = User( + username="adminuser", + email="admin@example.com", + password_hash=get_password_hash("adminpass123"), + auth_source="LOCAL" + ) + + user.roles.append(role) + + auth_repo.db.add(role) + auth_repo.db.add(user) + auth_repo.db.commit() + + retrieved_user = auth_repo.get_user_by_username("adminuser") + assert retrieved_user is not None + assert len(retrieved_user.roles) == 1 + assert retrieved_user.roles[0].name == "Admin" + +def test_ad_group_mapping(auth_repo): + """Test AD group mapping""" + role = Role(name="ADFS_Admin", description="ADFS administrators") + + auth_repo.db.add(role) + auth_repo.db.commit() + + mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id) + + auth_repo.db.add(mapping) + auth_repo.db.commit() + + retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first() + assert retrieved_mapping is not None + assert retrieved_mapping.role_id == role.id diff --git a/frontend/src/components/TaskHistory.svelte b/frontend/src/components/TaskHistory.svelte index 17532835..0673207f 100644 --- a/frontend/src/components/TaskHistory.svelte +++ b/frontend/src/components/TaskHistory.svelte @@ -21,7 +21,9 @@ // @POST: tasks array is updated and selectedTask status synchronized. async function fetchTasks() { try { - const res = await fetch('/api/tasks?limit=10'); + const token = localStorage.getItem('auth_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + const res = await fetch('/api/tasks?limit=10', { headers }); if (!res.ok) throw new Error('Failed to fetch tasks'); tasks = await res.json(); @@ -58,7 +60,9 @@ const params = new URLSearchParams(); if (status) params.append('status', status); - const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE' }); + const token = localStorage.getItem('auth_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE', headers }); if (!res.ok) throw new Error('Failed to clear tasks'); await fetchTasks(); @@ -75,7 +79,9 @@ async function selectTask(task) { try { // Fetch the full task details (including logs) before setting it as selected - const res = await fetch(`/api/tasks/${task.id}`); + const token = localStorage.getItem('auth_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + const res = await fetch(`/api/tasks/${task.id}`, { headers }); if (res.ok) { const fullTask = await res.json(); selectedTask.set(fullTask); diff --git a/frontend/src/components/tools/DebugTool.svelte b/frontend/src/components/tools/DebugTool.svelte index 1f209826..d4471c4a 100644 --- a/frontend/src/components/tools/DebugTool.svelte +++ b/frontend/src/components/tools/DebugTool.svelte @@ -8,6 +8,7 @@