This commit is contained in:
2026-01-27 23:49:19 +03:00
parent d3c3a80ed2
commit e7b31accd6
33 changed files with 58782 additions and 79457 deletions

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ from ..core.database import get_auth_db
from ..services.auth_service import AuthService from ..services.auth_service import AuthService
from ..schemas.auth import Token, User as UserSchema from ..schemas.auth import Token, User as UserSchema
from ..dependencies import get_current_user 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.auth.logger import log_security_event
from ..core.logger import belief_scope from ..core.logger import belief_scope
import starlette.requests import starlette.requests
@@ -85,6 +85,11 @@ async def logout(current_user: UserSchema = Depends(get_current_user)):
@router.get("/login/adfs") @router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request): async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"): 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') redirect_uri = request.url_for('auth_callback_adfs')
return await oauth.adfs.authorize_redirect(request, str(redirect_uri)) return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function] # [/DEF:login_adfs:Function]
@@ -95,6 +100,11 @@ async def login_adfs(request: starlette.requests.Request):
@router.get("/callback/adfs", name="auth_callback_adfs") @router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)): async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"): 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) token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo') user_info = token.get('userinfo')
if not user_info: if not user_info:

View File

@@ -22,7 +22,7 @@ from ...schemas.auth import (
) )
from ...models.auth import User, Role, Permission, ADGroupMapping from ...models.auth import User, Role, Permission, ADGroupMapping
from ...dependencies import has_permission, get_current_user from ...dependencies import has_permission, get_current_user
from ...core.logger import belief_scope from ...core.logger import logger, belief_scope
# [/SECTION] # [/SECTION]
# [DEF:router:Variable] # [DEF:router:Variable]
@@ -126,13 +126,17 @@ async def delete_user(
_ = Depends(has_permission("admin:users", "WRITE")) _ = Depends(has_permission("admin:users", "WRITE"))
): ):
with belief_scope("api.admin.delete_user"): with belief_scope("api.admin.delete_user"):
logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}")
repo = AuthRepository(db) repo = AuthRepository(db)
user = repo.get_user_by_id(user_id) user = repo.get_user_by_id(user_id)
if not user: 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") 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.delete(user)
db.commit() db.commit()
logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}")
return None return None
# [/DEF:delete_user:Function] # [/DEF:delete_user:Function]

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional 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 ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...core.config_models import Environment as EnvModel from ...core.config_models import Environment as EnvModel
@@ -47,7 +47,10 @@ class DatabaseResponse(BaseModel):
# @POST: Returns a list of EnvironmentResponse objects. # @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse] # @RETURN: List[EnvironmentResponse]
@router.get("", response_model=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"): with belief_scope("get_environments"):
envs = config_manager.get_environments() envs = config_manager.get_environments()
# Ensure envs is a list # Ensure envs is a list
@@ -77,7 +80,8 @@ async def update_environment_schedule(
id: str, id: str,
schedule: ScheduleSchema, schedule: ScheduleSchema,
config_manager=Depends(get_config_manager), 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}"): with belief_scope("update_environment_schedule", f"id={id}"):
envs = config_manager.get_environments() envs = config_manager.get_environments()
@@ -104,7 +108,11 @@ async def update_environment_schedule(
# @PARAM: id (str) - The environment ID. # @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases. # @RETURN: List[Dict] - List of databases.
@router.get("/{id}/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}"): with belief_scope("get_environment_databases", f"id={id}"):
envs = config_manager.get_environments() envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None) env = next((e for e in envs if e.id == id), None)

View File

@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import typing 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.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import ( 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. # @POST: Returns a list of all GitServerConfig objects from the database.
# @RETURN: List[GitServerConfigSchema] # @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=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"): with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all() return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function] # [/DEF:get_git_configs:Function]
@@ -46,7 +49,11 @@ async def get_git_configs(db: Session = Depends(get_db)):
# @PARAM: config (GitServerConfigCreate) # @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema # @RETURN: GitServerConfigSchema
@router.post("/config", response_model=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"): with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict()) db_config = GitServerConfig(**config.dict())
db.add(db_config) 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. # @POST: The configuration record is removed from the database.
# @PARAM: config_id (str) # @PARAM: config_id (str)
@router.delete("/config/{config_id}") @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"): with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first() db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config: 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. # @POST: Returns success if the connection is validated via GitService.
# @PARAM: config (GitServerConfigCreate) # @PARAM: config (GitServerConfigCreate)
@router.post("/config/test") @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"): with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat) success = await git_service.test_connection(config.provider, config.url, config.pat)
if success: if success:
@@ -94,7 +108,12 @@ async def test_git_config(config: GitServerConfigCreate):
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest) # @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init") @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"): with belief_scope("init_repository"):
# 1. Get config # 1. Get config
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first() 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) # @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema] # @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=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"): with belief_scope("get_branches"):
try: try:
return git_service.list_branches(dashboard_id) return git_service.list_branches(dashboard_id)
@@ -153,7 +175,11 @@ async def get_branches(dashboard_id: int):
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate) # @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches") @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"): with belief_scope("create_branch"):
try: try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch) 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: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout) # @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout") @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"): with belief_scope("checkout_branch"):
try: try:
git_service.checkout_branch(dashboard_id, checkout_data.name) 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: dashboard_id (int)
# @PARAM: commit_data (CommitCreate) # @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit") @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"): with belief_scope("commit_changes"):
try: try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files) 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. # @POST: Local commits are pushed to the remote repository.
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push") @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"): with belief_scope("push_changes"):
try: try:
git_service.push_changes(dashboard_id) 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. # @POST: Remote changes are fetched and merged into the local branch.
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull") @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"): with belief_scope("pull_changes"):
try: try:
git_service.pull_changes(dashboard_id) git_service.pull_changes(dashboard_id)
@@ -231,7 +271,11 @@ async def pull_changes(dashboard_id: int):
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str]) # @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync") @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"): with belief_scope("sync_dashboard"):
try: try:
from src.plugins.git_plugin import GitPlugin 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. # @POST: Returns a list of DeploymentEnvironmentSchema objects.
# @RETURN: List[DeploymentEnvironmentSchema] # @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=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"): with belief_scope("get_environments"):
envs = config_manager.get_environments() envs = config_manager.get_environments()
return [ return [
@@ -271,7 +318,11 @@ async def get_environments(config_manager=Depends(get_config_manager)):
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest) # @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy") @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"): with belief_scope("deploy_dashboard"):
try: try:
from src.plugins.git_plugin import GitPlugin from src.plugins.git_plugin import GitPlugin
@@ -293,7 +344,11 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
# @PARAM: limit (int) # @PARAM: limit (int)
# @RETURN: List[CommitSchema] # @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=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"): with belief_scope("get_history"):
try: try:
return git_service.get_commit_history(dashboard_id, limit) 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) # @PARAM: dashboard_id (int)
# @RETURN: dict # @RETURN: dict
@router.get("/repositories/{dashboard_id}/status") @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"): with belief_scope("get_repository_status"):
try: try:
return git_service.get_status(dashboard_id) return git_service.get_status(dashboard_id)
@@ -325,7 +383,12 @@ async def get_repository_status(dashboard_id: int):
# @PARAM: staged (bool) # @PARAM: staged (bool)
# @RETURN: str # @RETURN: str
@router.get("/repositories/{dashboard_id}/diff") @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"): with belief_scope("get_repository_diff"):
try: try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged) diff_text = git_service.get_diff(dashboard_id, file_path, staged)

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from ...core.logger import belief_scope 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 ...core.database import get_db
from ...models.mapping import DatabaseMapping from ...models.mapping import DatabaseMapping
from pydantic import BaseModel from pydantic import BaseModel
@@ -60,7 +60,8 @@ class SuggestRequest(BaseModel):
async def get_mappings( async def get_mappings(
source_env_id: Optional[str] = None, source_env_id: Optional[str] = None,
target_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"): with belief_scope("get_mappings"):
query = db.query(DatabaseMapping) query = db.query(DatabaseMapping)
@@ -76,7 +77,11 @@ async def get_mappings(
# @PRE: mapping is valid MappingCreate, db session is injected. # @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database. # @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse) @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"): with belief_scope("create_mapping"):
# Check if mapping already exists # Check if mapping already exists
existing = db.query(DatabaseMapping).filter( existing = db.query(DatabaseMapping).filter(
@@ -106,10 +111,11 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
@router.post("/suggest") @router.post("/suggest")
async def suggest_mappings_api( async def suggest_mappings_api(
request: SuggestRequest, 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"): with belief_scope("suggest_mappings_api"):
from backend.src.services.mapping_service import MappingService from ...services.mapping_service import MappingService
service = MappingService(config_manager) service = MappingService(config_manager)
try: try:
return await service.get_suggestions(request.source_env_id, request.target_env_id) return await service.get_suggestions(request.source_env_id, request.target_env_id)

View File

@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict 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 ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope 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. # @PARAM: env_id (str) - The ID of the environment to fetch from.
# @RETURN: List[DashboardMetadata] # @RETURN: List[DashboardMetadata]
@router.get("/environments/{env_id}/dashboards", response_model=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}"): with belief_scope("get_dashboards", f"env_id={env_id}"):
environments = config_manager.get_environments() environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None) 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. # @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str} # @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute") @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"): with belief_scope("execute_migration"):
# Validate environments exist # Validate environments exist
environments = config_manager.get_environments() environments = config_manager.get_environments()

View File

@@ -7,7 +7,7 @@ from typing import List
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ...core.plugin_base import PluginConfig 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 from ...core.logger import belief_scope
router = APIRouter() router = APIRouter()
@@ -19,7 +19,8 @@ router = APIRouter()
# @RETURN: List[PluginConfig] - List of registered plugins. # @RETURN: List[PluginConfig] - List of registered plugins.
@router.get("", response_model=List[PluginConfig]) @router.get("", response_model=List[PluginConfig])
async def list_plugins( 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"): with belief_scope("list_plugins"):
""" """

View File

@@ -31,7 +31,7 @@ router = APIRouter()
@router.get("", response_model=AppConfig) @router.get("", response_model=AppConfig)
async def get_settings( async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ")) _ = Depends(has_permission("admin:settings", "READ"))
): ):
with belief_scope("get_settings"): with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings") logger.info("[get_settings][Entry] Fetching all settings")
@@ -53,7 +53,7 @@ async def get_settings(
async def update_global_settings( async def update_global_settings(
settings: GlobalSettings, settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE")) _ = Depends(has_permission("admin:settings", "WRITE"))
): ):
with belief_scope("update_global_settings"): with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating 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) @router.get("/storage", response_model=StorageConfig)
async def get_storage_settings( async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ")) _ = Depends(has_permission("admin:settings", "READ"))
): ):
with belief_scope("get_storage_settings"): with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage return config_manager.get_config().settings.storage
@@ -83,7 +83,7 @@ async def get_storage_settings(
async def update_storage_settings( async def update_storage_settings(
storage: StorageConfig, storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE")) _ = Depends(has_permission("admin:settings", "WRITE"))
): ):
with belief_scope("update_storage_settings"): with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path) 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]) @router.get("/environments", response_model=List[Environment])
async def get_environments( async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "READ")) _ = Depends(has_permission("admin:settings", "READ"))
): ):
with belief_scope("get_environments"): with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments") logger.info("[get_environments][Entry] Fetching environments")
@@ -121,7 +121,7 @@ async def get_environments(
async def add_environment( async def add_environment(
env: Environment, env: Environment,
config_manager: ConfigManager = Depends(get_config_manager), config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("settings", "WRITE")) _ = Depends(has_permission("admin:settings", "WRITE"))
): ):
with belief_scope("add_environment"): with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}") logger.info(f"[add_environment][Entry] Adding environment {env.id}")

View File

@@ -8,11 +8,12 @@
# @INVARIANT: All paths must be validated against path traversal. # @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from typing import List, Optional from typing import List, Optional
from ...models.storage import StoredFile, FileCategory 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 ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope from ...core.logger import belief_scope
# [/SECTION] # [/SECTION]
@@ -34,7 +35,8 @@ router = APIRouter(tags=["storage"])
async def list_files( async def list_files(
category: Optional[FileCategory] = None, category: Optional[FileCategory] = None,
path: Optional[str] = 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"): with belief_scope("list_files"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -63,7 +65,8 @@ async def upload_file(
category: FileCategory = Form(...), category: FileCategory = Form(...),
path: Optional[str] = Form(None), path: Optional[str] = Form(None),
file: UploadFile = File(...), 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"): with belief_scope("upload_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -89,7 +92,12 @@ async def upload_file(
# #
# @RELATION: CALLS -> StoragePlugin.delete_file # @RELATION: CALLS -> StoragePlugin.delete_file
@router.delete("/files/{category}/{path:path}", status_code=204) @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"): with belief_scope("delete_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin: 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 # @RELATION: CALLS -> StoragePlugin.get_file_path
@router.get("/download/{category}/{path: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"): with belief_scope("download_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager") storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin: if not storage_plugin:

View File

@@ -64,7 +64,8 @@ async def list_tasks(
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
status: Optional[TaskStatus] = None, 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. Retrieve a list of tasks with pagination and optional status filter.
@@ -83,7 +84,8 @@ async def list_tasks(
# @RETURN: Task - The task details. # @RETURN: Task - The task details.
async def get_task( async def get_task(
task_id: str, 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. Retrieve the details of a specific task.
@@ -105,7 +107,8 @@ async def get_task(
# @RETURN: List[LogEntry] - List of log entries. # @RETURN: List[LogEntry] - List of log entries.
async def get_task_logs( async def get_task_logs(
task_id: str, 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. Retrieve logs for a specific task.
@@ -129,7 +132,8 @@ async def get_task_logs(
async def resolve_task( async def resolve_task(
task_id: str, task_id: str,
request: ResolveTaskRequest, 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. Resolve a task that is awaiting mapping.
@@ -154,7 +158,8 @@ async def resolve_task(
async def resume_task( async def resume_task(
task_id: str, task_id: str,
request: ResumeTaskRequest, 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). 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. # @POST: Tasks are removed from memory/persistence.
async def clear_tasks( async def clear_tasks(
status: Optional[TaskStatus] = None, 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. Clear tasks matching the status filter. If no filter, clears all non-running tasks.

View File

@@ -35,6 +35,16 @@ def register_adfs():
) )
# [/DEF:register_adfs:Function] # [/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 # Initial registration
register_adfs() register_adfs()

View File

@@ -86,7 +86,7 @@ def get_scheduler_service() -> SchedulerService:
# [DEF:oauth2_scheme:Variable] # [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction. # @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:oauth2_scheme:Variable]
# [DEF:get_current_user:Function] # [DEF:get_current_user:Function]

View File

@@ -95,7 +95,7 @@ class ADGroupMapping(Base):
__tablename__ = "ad_group_mappings" __tablename__ = "ad_group_mappings"
id = Column(String, primary_key=True, default=generate_uuid) 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_id = Column(String, ForeignKey("roles.id"), nullable=False)
role = relationship("Role") role = relationship("Role")

View File

@@ -303,9 +303,9 @@ class MigrationPlugin(PluginBase):
try: try:
exported_content, _ = from_c.export_dashboard(dash_id) 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 # 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) success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
if not success and replace_db_config: if not success and replace_db_config:

View File

@@ -16,7 +16,8 @@ from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent.parent)) sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import AuthSessionLocal 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 from src.core.logger import logger, belief_scope
# [/SECTION] # [/SECTION]
@@ -29,6 +30,10 @@ INITIAL_PERMISSIONS = [
{"resource": "admin:roles", "action": "WRITE"}, {"resource": "admin:roles", "action": "WRITE"},
{"resource": "admin:settings", "action": "READ"}, {"resource": "admin:settings", "action": "READ"},
{"resource": "admin:settings", "action": "WRITE"}, {"resource": "admin:settings", "action": "WRITE"},
{"resource": "environments", "action": "READ"},
{"resource": "plugins", "action": "READ"},
{"resource": "tasks", "action": "READ"},
{"resource": "tasks", "action": "WRITE"},
# Plugin Permissions # Plugin Permissions
{"resource": "plugin:backup", "action": "EXECUTE"}, {"resource": "plugin:backup", "action": "EXECUTE"},
@@ -37,6 +42,8 @@ INITIAL_PERMISSIONS = [
{"resource": "plugin:search", "action": "EXECUTE"}, {"resource": "plugin:search", "action": "EXECUTE"},
{"resource": "plugin:git", "action": "EXECUTE"}, {"resource": "plugin:git", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "EXECUTE"}, {"resource": "plugin:storage", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "READ"},
{"resource": "plugin:storage", "action": "WRITE"},
{"resource": "plugin:debug", "action": "EXECUTE"}, {"resource": "plugin:debug", "action": "EXECUTE"},
] ]
# [/DEF:INITIAL_PERMISSIONS:Constant] # [/DEF:INITIAL_PERMISSIONS:Constant]
@@ -66,6 +73,36 @@ def seed_permissions():
db.commit() db.commit()
logger.info(f"Seeding completed. Added {count} new permissions.") 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: except Exception as e:
logger.error(f"Failed to seed permissions: {e}") logger.error(f"Failed to seed permissions: {e}")
db.rollback() db.rollback()

View File

@@ -101,7 +101,7 @@ class AuthService:
# Update roles based on group mappings # Update roles based on group mappings
from ..models.auth import ADGroupMapping from ..models.auth import ADGroupMapping
mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter( mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter(
ADGroupMapping.ad_group_name.in_(ad_groups) ADGroupMapping.ad_group.in_(ad_groups)
).all() ).all()
user.roles = mapped_roles user.roles = mapped_roles

View File

@@ -10,9 +10,9 @@
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import List, Dict from typing import List, Dict
from backend.src.core.logger import belief_scope from ..core.logger import belief_scope
from backend.src.core.superset_client import SupersetClient from ..core.superset_client import SupersetClient
from backend.src.core.utils.matching import suggest_mappings from ..core.utils.matching import suggest_mappings
# [/SECTION] # [/SECTION]
# [DEF:MappingService:Class] # [DEF:MappingService:Class]

Binary file not shown.

162
backend/tests/test_auth.py Normal file
View File

@@ -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

View File

@@ -21,7 +21,9 @@
// @POST: tasks array is updated and selectedTask status synchronized. // @POST: tasks array is updated and selectedTask status synchronized.
async function fetchTasks() { async function fetchTasks() {
try { 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'); if (!res.ok) throw new Error('Failed to fetch tasks');
tasks = await res.json(); tasks = await res.json();
@@ -58,7 +60,9 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
if (status) params.append('status', status); 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'); if (!res.ok) throw new Error('Failed to clear tasks');
await fetchTasks(); await fetchTasks();
@@ -75,7 +79,9 @@
async function selectTask(task) { async function selectTask(task) {
try { try {
// Fetch the full task details (including logs) before setting it as selected // 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) { if (res.ok) {
const fullTask = await res.json(); const fullTask = await res.json();
selectedTask.set(fullTask); selectedTask.set(fullTask);

View File

@@ -8,6 +8,7 @@
<script> <script>
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '../../lib/api.js';
import { runTask, getTaskStatus } from '../../services/toolsService.js'; import { runTask, getTaskStatus } from '../../services/toolsService.js';
import { selectedTask } from '../../lib/stores.js'; import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js'; import { addToast } from '../../lib/toasts.js';
@@ -32,8 +33,7 @@
*/ */
async function fetchEnvironments() { async function fetchEnvironments() {
try { try {
const res = await fetch('/api/environments'); envs = await api.getEnvironmentsList();
envs = await res.json();
} catch (e) { } catch (e) {
addToast('Failed to fetch environments', 'error'); addToast('Failed to fetch environments', 'error');
} }

View File

@@ -53,9 +53,15 @@ async function fetchApi(endpoint) {
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`); const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
} }
if (response.status === 204) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error); console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
@@ -80,9 +86,15 @@ async function postApi(endpoint, body) {
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`); const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
} }
if (response.status === 204) return null;
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error); console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
@@ -107,13 +119,19 @@ async function requestApi(endpoint, method = 'GET', body = null) {
options.body = JSON.stringify(body); options.body = JSON.stringify(body);
} }
const response = await fetch(`${API_BASE_URL}${endpoint}`, options); const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
const message = errorData.detail const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) ? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`; : `API request failed with status ${response.status}`;
console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${message}'}}`);
throw new Error(message); throw new Error(message);
} }
if (response.status === 204) {
console.log('[api.requestApi][Action] 204 No Content received');
return null;
}
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error); console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error);

View File

@@ -21,6 +21,7 @@
let roles = []; let roles = [];
let loading = true; let loading = true;
let error = null; let error = null;
let deletingUserId = null;
let showModal = false; let showModal = false;
let isEditing = false; let isEditing = false;
@@ -130,9 +131,11 @@
* @param {Object} user - The user to delete. * @param {Object} user - The user to delete.
*/ */
async function handleDeleteUser(user) { async function handleDeleteUser(user) {
if (deletingUserId) return;
if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return; if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return;
console.log('[AdminUsersPage][handleDeleteUser][Entry]'); console.log('[AdminUsersPage][handleDeleteUser][Entry]');
deletingUserId = user.id;
try { try {
await adminService.deleteUser(user.id); await adminService.deleteUser(user.id);
await loadData(); await loadData();
@@ -140,6 +143,8 @@
} catch (e) { } catch (e) {
alert("Failed to delete user: " + e.message); alert("Failed to delete user: " + e.message);
console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e); console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e);
} finally {
deletingUserId = null;
} }
} }
// [/DEF:handleDeleteUser:Function] // [/DEF:handleDeleteUser:Function]
@@ -208,8 +213,14 @@
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button> <button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</button>
<button on:click={() => handleDeleteUser(user)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button> <button
on:click={() => handleDeleteUser(user)}
class="text-red-600 hover:text-red-900 disabled:opacity-50"
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? ($t.common.deleting || 'Deleting...') : $t.common.delete}
</button>
</td> </td>
</tr> </tr>
{/each} {/each}

View File

@@ -8,6 +8,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte'; import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js'; import { addToast as toast } from '../../lib/toasts.js';
import { api } from '../../lib/api.js';
import type { DashboardMetadata } from '../../types/dashboard'; import type { DashboardMetadata } from '../../types/dashboard';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui'; import { Button, Card, PageHeader, Select } from '$lib/ui';
@@ -24,9 +25,7 @@
// @POST: `environments` array is populated with data from /api/environments. // @POST: `environments` array is populated with data from /api/environments.
async function fetchEnvironments() { async function fetchEnvironments() {
try { try {
const response = await fetch('/api/environments'); environments = await api.getEnvironmentsList();
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
if (environments.length > 0) { if (environments.length > 0) {
selectedEnvId = environments[0].id; selectedEnvId = environments[0].id;
} }
@@ -46,9 +45,7 @@
if (!envId) return; if (!envId) return;
fetchingDashboards = true; fetchingDashboards = true;
try { try {
const response = await fetch(`/api/environments/${envId}/dashboards`); dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
} catch (e) { } catch (e) {
toast(e.message, 'error'); toast(e.message, 'error');
dashboards = []; dashboards = [];

View File

@@ -18,6 +18,7 @@
import TaskHistory from '../../components/TaskHistory.svelte'; import TaskHistory from '../../components/TaskHistory.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte'; import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import PasswordPrompt from '../../components/PasswordPrompt.svelte'; import PasswordPrompt from '../../components/PasswordPrompt.svelte';
import { api } from '../../lib/api.js';
import { selectedTask } from '../../lib/stores.js'; import { selectedTask } from '../../lib/stores.js';
import { resumeTask } from '../../services/taskService.js'; import { resumeTask } from '../../services/taskService.js';
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard'; import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
@@ -58,9 +59,7 @@
*/ */
async function fetchEnvironments() { async function fetchEnvironments() {
try { try {
const response = await fetch('/api/environments'); environments = await api.getEnvironmentsList();
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
} catch (e) { } catch (e) {
error = e.message; error = e.message;
} finally { } finally {
@@ -78,9 +77,7 @@
*/ */
async function fetchDashboards(envId: string) { async function fetchDashboards(envId: string) {
try { try {
const response = await fetch(`/api/environments/${envId}/dashboards`); dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
selectedDashboardIds = []; // Reset selection when env changes selectedDashboardIds = []; // Reset selection when env changes
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -106,23 +103,17 @@
error = ""; error = "";
try { try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([ const [src, tgt, maps, sugs] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`), api.requestApi(`/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`), api.requestApi(`/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`), api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, { api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
]); ]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments'); sourceDatabases = src;
targetDatabases = tgt;
sourceDatabases = await srcRes.json(); mappings = maps;
targetDatabases = await tgtRes.json(); suggestions = sugs;
mappings = await mapRes.json();
suggestions = await sugRes.json();
} catch (e) { } catch (e) {
error = e.message; error = e.message;
} finally { } finally {
@@ -145,22 +136,15 @@
if (!sDb || !tDb) return; if (!sDb || !tDb) return;
try { try {
const response = await fetch('/api/mappings', { const savedMapping = await api.postApi('/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId, source_env_id: sourceEnvId,
target_env_id: targetEnvId, target_env_id: targetEnvId,
source_db_uuid: sourceUuid, source_db_uuid: sourceUuid,
target_db_uuid: targetUuid, target_db_uuid: targetUuid,
source_db_name: sDb.database_name, source_db_name: sDb.database_name,
target_db_name: tDb.database_name target_db_name: tDb.database_name
})
}); });
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping]; mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
} catch (e) { } catch (e) {
error = e.message; error = e.message;
@@ -253,14 +237,7 @@
replace_db_config: replaceDb replace_db_config: replaceDb
}; };
console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection); console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection);
const response = await fetch('/api/migration/execute', { const result = await api.postApi('/migration/execute', selection);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selection)
});
console.log(`[MigrationDashboard][Action] API response status: ${response.status}`);
if (!response.ok) throw new Error(`Failed to start migration: ${response.status} ${response.statusText}`);
const result = await response.json();
console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`); console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`);
// Wait a brief moment for the backend to ensure the task is retrievable // Wait a brief moment for the backend to ensure the task is retrievable
@@ -268,11 +245,9 @@
// Fetch full task details and switch to TaskRunner view // Fetch full task details and switch to TaskRunner view
try { try {
const taskRes = await fetch(`/api/tasks/${result.task_id}`); const task = await api.getTask(result.task_id);
if (taskRes.ok) {
const task = await taskRes.json();
selectedTask.set(task); selectedTask.set(task);
} else { } catch (fetchErr) {
// Fallback: create a temporary task object to switch view immediately // Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder."); console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({ selectedTask.set({
@@ -283,9 +258,6 @@
params: {} params: {}
}); });
} }
} catch (fetchErr) {
console.error("Failed to fetch new task details:", fetchErr);
}
} catch (e) { } catch (e) {
console.error(`[MigrationDashboard][Failure] Migration failed:`, e); console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
error = e.message; error = e.message;

View File

@@ -12,6 +12,7 @@
<script lang="ts"> <script lang="ts">
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '../../../lib/api.js';
import EnvSelector from '../../../components/EnvSelector.svelte'; import EnvSelector from '../../../components/EnvSelector.svelte';
import MappingTable from '../../../components/MappingTable.svelte'; import MappingTable from '../../../components/MappingTable.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
@@ -38,9 +39,7 @@
// @POST: environments array is populated. // @POST: environments array is populated.
async function fetchEnvironments() { async function fetchEnvironments() {
try { try {
const response = await fetch('/api/environments'); environments = await api.getEnvironmentsList();
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
} catch (e) { } catch (e) {
error = e.message; error = e.message;
} finally { } finally {
@@ -64,23 +63,17 @@
success = ""; success = "";
try { try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([ const [src, tgt, maps, sugs] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`), api.requestApi(`/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`), api.requestApi(`/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`), api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, { api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
]); ]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments'); sourceDatabases = src;
targetDatabases = tgt;
sourceDatabases = await srcRes.json(); mappings = maps;
targetDatabases = await tgtRes.json(); suggestions = sugs;
mappings = await mapRes.json();
suggestions = await sugRes.json();
} catch (e) { } catch (e) {
error = e.message; error = e.message;
} finally { } finally {
@@ -103,22 +96,15 @@
if (!sDb || !tDb) return; if (!sDb || !tDb) return;
try { try {
const response = await fetch('/api/mappings', { const savedMapping = await api.postApi('/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId, source_env_id: sourceEnvId,
target_env_id: targetEnvId, target_env_id: targetEnvId,
source_db_uuid: sourceUuid, source_db_uuid: sourceUuid,
target_db_uuid: targetUuid, target_db_uuid: targetUuid,
source_db_name: sDb.database_name, source_db_name: sDb.database_name,
target_db_name: tDb.database_name target_db_name: tDb.database_name
})
}); });
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping]; mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
success = "Mapping saved successfully"; success = "Mapping saved successfully";
} catch (e) { } catch (e) {

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Connection Management API. * Service for interacting with the Connection Management API.
*/ */
const API_BASE = '/api/settings/connections'; import { requestApi } from '../lib/api';
const API_BASE = '/settings/connections';
// [DEF:getConnections:Function] // [DEF:getConnections:Function]
/* @PURPOSE: Fetch a list of saved connections. /* @PURPOSE: Fetch a list of saved connections.
@@ -14,11 +16,7 @@ const API_BASE = '/api/settings/connections';
* @returns {Promise<Array>} List of connections. * @returns {Promise<Array>} List of connections.
*/ */
export async function getConnections() { export async function getConnections() {
const response = await fetch(API_BASE); return requestApi(API_BASE);
if (!response.ok) {
throw new Error(`Failed to fetch connections: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:getConnections:Function] // [/DEF:getConnections:Function]
@@ -33,19 +31,7 @@ export async function getConnections() {
* @returns {Promise<Object>} The created connection instance. * @returns {Promise<Object>} The created connection instance.
*/ */
export async function createConnection(connectionData) { export async function createConnection(connectionData) {
const response = await fetch(API_BASE, { return requestApi(API_BASE, 'POST', connectionData);
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(connectionData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to create connection: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:createConnection:Function] // [/DEF:createConnection:Function]
@@ -59,12 +45,6 @@ export async function createConnection(connectionData) {
* @param {string} connectionId - The ID of the connection to delete. * @param {string} connectionId - The ID of the connection to delete.
*/ */
export async function deleteConnection(connectionId) { export async function deleteConnection(connectionId) {
const response = await fetch(`${API_BASE}/${connectionId}`, { return requestApi(`${API_BASE}/${connectionId}`, 'DELETE');
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete connection: ${response.statusText}`);
}
} }
// [/DEF:deleteConnection:Function] // [/DEF:deleteConnection:Function]

View File

@@ -6,7 +6,9 @@
* @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md * @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/ */
const API_BASE = '/api/git'; import { requestApi } from '../lib/api';
const API_BASE = '/git';
// [DEF:gitService:Action] // [DEF:gitService:Action]
export const gitService = { export const gitService = {
@@ -19,9 +21,7 @@ export const gitService = {
*/ */
async getConfigs() { async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs'); console.log('[getConfigs][Action] Fetching Git configs');
const response = await fetch(`${API_BASE}/config`); return requestApi(`${API_BASE}/config`);
if (!response.ok) throw new Error('Failed to fetch Git configs');
return response.json();
}, },
/** /**
@@ -34,13 +34,7 @@ export const gitService = {
*/ */
async createConfig(config) { async createConfig(config) {
console.log('[createConfig][Action] Creating Git config'); console.log('[createConfig][Action] Creating Git config');
const response = await fetch(`${API_BASE}/config`, { return requestApi(`${API_BASE}/config`, 'POST', config);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('Failed to create Git config');
return response.json();
}, },
/** /**
@@ -53,11 +47,7 @@ export const gitService = {
*/ */
async deleteConfig(configId) { async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`); console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
const response = await fetch(`${API_BASE}/config/${configId}`, { return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete Git config');
return response.json();
}, },
/** /**
@@ -70,12 +60,7 @@ export const gitService = {
*/ */
async testConnection(config) { async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection'); console.log('[testConnection][Action] Testing Git connection');
const response = await fetch(`${API_BASE}/config/test`, { return requestApi(`${API_BASE}/config/test`, 'POST', config);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return response.json();
}, },
/** /**
@@ -90,16 +75,10 @@ export const gitService = {
*/ */
async initRepository(dashboardId, configId, remoteUrl) { async initRepository(dashboardId, configId, remoteUrl) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`); console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/init`, 'POST', {
method: 'POST', config_id: configId,
headers: { 'Content-Type': 'application/json' }, remote_url: remoteUrl
body: JSON.stringify({ config_id: configId, remote_url: remoteUrl })
}); });
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to initialize repository');
}
return response.json();
}, },
/** /**
@@ -112,9 +91,7 @@ export const gitService = {
*/ */
async getBranches(dashboardId) { async getBranches(dashboardId) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`); console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`); return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`);
if (!response.ok) throw new Error('Failed to fetch branches');
return response.json();
}, },
/** /**
@@ -129,13 +106,10 @@ export const gitService = {
*/ */
async createBranch(dashboardId, name, fromBranch) { async createBranch(dashboardId, name, fromBranch) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`); console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`, 'POST', {
method: 'POST', name,
headers: { 'Content-Type': 'application/json' }, from_branch: fromBranch
body: JSON.stringify({ name, from_branch: fromBranch })
}); });
if (!response.ok) throw new Error('Failed to create branch');
return response.json();
}, },
/** /**
@@ -149,13 +123,7 @@ export const gitService = {
*/ */
async checkoutBranch(dashboardId, name) { async checkoutBranch(dashboardId, name) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`); console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/checkout`, 'POST', { name });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error('Failed to checkout branch');
return response.json();
}, },
/** /**
@@ -170,13 +138,7 @@ export const gitService = {
*/ */
async commit(dashboardId, message, files) { async commit(dashboardId, message, files) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`); console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/commit`, 'POST', { message, files });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, files })
});
if (!response.ok) throw new Error('Failed to commit changes');
return response.json();
}, },
/** /**
@@ -189,11 +151,7 @@ export const gitService = {
*/ */
async push(dashboardId) { async push(dashboardId) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`); console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/push`, 'POST');
method: 'POST'
});
if (!response.ok) throw new Error('Failed to push changes');
return response.json();
}, },
/** /**
@@ -206,11 +164,7 @@ export const gitService = {
*/ */
async pull(dashboardId) { async pull(dashboardId) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`); console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/pull`, 'POST');
method: 'POST'
});
if (!response.ok) throw new Error('Failed to pull changes');
return response.json();
}, },
/** /**
@@ -221,9 +175,7 @@ export const gitService = {
*/ */
async getEnvironments() { async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments'); console.log('[getEnvironments][Action] Fetching environments');
const response = await fetch(`${API_BASE}/environments`); return requestApi(`${API_BASE}/environments`);
if (!response.ok) throw new Error('Failed to fetch environments');
return response.json();
}, },
/** /**
@@ -237,13 +189,9 @@ export const gitService = {
*/ */
async deploy(dashboardId, environmentId) { async deploy(dashboardId, environmentId) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`); console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, { return requestApi(`${API_BASE}/repositories/${dashboardId}/deploy`, 'POST', {
method: 'POST', environment_id: environmentId
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId })
}); });
if (!response.ok) throw new Error('Failed to deploy dashboard');
return response.json();
}, },
/** /**
@@ -255,9 +203,7 @@ export const gitService = {
*/ */
async getHistory(dashboardId, limit = 50) { async getHistory(dashboardId, limit = 50) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`); console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`); return requestApi(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch commit history');
return response.json();
}, },
/** /**
@@ -269,17 +215,9 @@ export const gitService = {
*/ */
async sync(dashboardId, sourceEnvId = null) { async sync(dashboardId, sourceEnvId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardId}`); console.log(`[sync][Action] Syncing dashboard ${dashboardId}`);
const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`); let endpoint = `${API_BASE}/repositories/${dashboardId}/sync`;
if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId); if (sourceEnvId) endpoint += `?source_env_id=${sourceEnvId}`;
return requestApi(endpoint, 'POST');
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to sync dashboard');
}
return response.json();
}, },
/** /**
@@ -292,9 +230,7 @@ export const gitService = {
*/ */
async getStatus(dashboardId) { async getStatus(dashboardId) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`); console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`); return requestApi(`${API_BASE}/repositories/${dashboardId}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return response.json();
}, },
/** /**
@@ -309,15 +245,12 @@ export const gitService = {
*/ */
async getDiff(dashboardId, filePath = null, staged = false) { async getDiff(dashboardId, filePath = null, staged = false) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`); console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`);
let url = `${API_BASE}/repositories/${dashboardId}/diff`; let endpoint = `${API_BASE}/repositories/${dashboardId}/diff`;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filePath) params.append('file_path', filePath); if (filePath) params.append('file_path', filePath);
if (staged) params.append('staged', 'true'); if (staged) params.append('staged', 'true');
if (params.toString()) url += `?${params.toString()}`; if (params.toString()) endpoint += `?${params.toString()}`;
return requestApi(endpoint);
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch diff');
return response.json();
} }
}; };
// [/DEF:gitService:Action] // [/DEF:gitService:Action]

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Task Management API. * Service for interacting with the Task Management API.
*/ */
const API_BASE = '/api/tasks'; import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:getTasks:Function] // [DEF:getTasks:Function]
/* @PURPOSE: Fetch a list of tasks with pagination and optional status filter. /* @PURPOSE: Fetch a list of tasks with pagination and optional status filter.
@@ -25,11 +27,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
params.append('status', status); params.append('status', status);
} }
const response = await fetch(`${API_BASE}?${params.toString()}`); return requestApi(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:getTasks:Function] // [/DEF:getTasks:Function]
@@ -44,11 +42,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
* @returns {Promise<Object>} Task details. * @returns {Promise<Object>} Task details.
*/ */
export async function getTask(taskId) { export async function getTask(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`); return requestApi(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:getTask:Function] // [/DEF:getTask:Function]
@@ -86,19 +80,7 @@ export async function getTaskLogs(taskId) {
* @returns {Promise<Object>} Updated task object. * @returns {Promise<Object>} Updated task object.
*/ */
export async function resumeTask(taskId, passwords) { export async function resumeTask(taskId, passwords) {
const response = await fetch(`${API_BASE}/${taskId}/resume`, { return requestApi(`${API_BASE}/${taskId}/resume`, 'POST', { passwords });
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ passwords })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resume task: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:resumeTask:Function] // [/DEF:resumeTask:Function]
@@ -114,19 +96,7 @@ export async function resumeTask(taskId, passwords) {
* @returns {Promise<Object>} Updated task object. * @returns {Promise<Object>} Updated task object.
*/ */
export async function resolveTask(taskId, resolutionParams) { export async function resolveTask(taskId, resolutionParams) {
const response = await fetch(`${API_BASE}/${taskId}/resolve`, { return requestApi(`${API_BASE}/${taskId}/resolve`, 'POST', { resolution_params: resolutionParams });
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ resolution_params: resolutionParams })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resolve task: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:resolveTask:Function] // [/DEF:resolveTask:Function]
@@ -145,12 +115,6 @@ export async function clearTasks(status = null) {
params.append('status', status); params.append('status', status);
} }
const response = await fetch(`${API_BASE}?${params.toString()}`, { return requestApi(`${API_BASE}?${params.toString()}`, 'DELETE');
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to clear tasks: ${response.statusText}`);
}
} }
// [/DEF:clearTasks:Function] // [/DEF:clearTasks:Function]

View File

@@ -2,7 +2,9 @@
* Service for generic Task API communication used by Tools. * Service for generic Task API communication used by Tools.
*/ */
const API_BASE = '/api/tasks'; import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:runTask:Function] // [DEF:runTask:Function]
/* @PURPOSE: Start a new task for a given plugin. /* @PURPOSE: Start a new task for a given plugin.
@@ -16,19 +18,7 @@ const API_BASE = '/api/tasks';
* @returns {Promise<Object>} The created task instance. * @returns {Promise<Object>} The created task instance.
*/ */
export async function runTask(pluginId, params) { export async function runTask(pluginId, params) {
const response = await fetch(API_BASE, { return requestApi(API_BASE, 'POST', { plugin_id: pluginId, params });
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ plugin_id: pluginId, params })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to start task: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:runTask:Function] // [/DEF:runTask:Function]
@@ -43,10 +33,6 @@ export async function runTask(pluginId, params) {
* @returns {Promise<Object>} Task details. * @returns {Promise<Object>} Task details.
*/ */
export async function getTaskStatus(taskId) { export async function getTaskStatus(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`); return requestApi(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
} }
// [/DEF:getTaskStatus:Function] // [/DEF:getTaskStatus:Function]

View File

@@ -44,13 +44,13 @@
- [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py` - [x] T021 [US2] Protect existing plugin API routes using `has_permission` in `backend/src/api/routes/*.py`
- [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py` - [x] T022 [US2] Implement `SystemAdminPlugin` inheriting from `PluginBase` for User/Role management in `backend/src/plugins/system_admin.py`
- [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py` - [x] T023 [US2] Implement Admin API endpoints within `SystemAdminPlugin` in `backend/src/api/routes/admin.py`
- [ ] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py` - [x] T053 [US2] Extend Admin API with User Update/Delete and Role CRUD endpoints in `backend/src/api/routes/admin.py`
- [ ] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py` - [x] T054 [US2] Add Pydantic schemas for UserUpdate, RoleCreate, RoleUpdate in `backend/src/schemas/auth.py`
- [x] T051 [US2] Implement `adminService.js` for frontend API orchestration - [x] T051 [US2] Implement `adminService.js` for frontend API orchestration
- [ ] T055 [US2] Update `adminService.js` with new CRUD methods - [x] T055 [US2] Update `adminService.js` with new CRUD methods
- [x] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte` - [x] T024 [US2] Create Admin Dashboard UI using `src/lib/ui` and `src/lib/i18n` in `frontend/src/routes/admin/users/+page.svelte`
- [ ] T056 [US2] Update Admin User Dashboard to support Edit/Delete operations in `frontend/src/routes/admin/users/+page.svelte` - [x] T056 [US2] Update Admin User Dashboard to support Edit/Delete operations in `frontend/src/routes/admin/users/+page.svelte`
- [ ] T057 [US4] Create Role Management UI in `frontend/src/routes/admin/roles/+page.svelte` - [x] T057 [US4] Create Role Management UI in `frontend/src/routes/admin/roles/+page.svelte`
- [x] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte` - [x] T025 [US2] Update Navigation Bar to hide links and show user profile/logout using `src/lib/ui` in `frontend/src/components/Navbar.svelte`
- [x] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte` - [x] T042 [US2] Implement `PermissionGuard` frontend component for granular UI element protection in `frontend/src/components/auth/PermissionGuard.svelte`
- [x] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py` - [x] T045 [US2] Implement multi-role permission resolution logic (union of permissions) in `backend/src/services/auth_service.py`