diff --git a/app/config.py b/app/config.py index 3c9aa2a..20b9c7f 100644 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,7 @@ import os DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db") SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production") ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 heures +ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 8 heures APP_NAME = "PatchCenter" APP_VERSION = "2.0" diff --git a/app/dependencies.py b/app/dependencies.py index ab23deb..4773fbe 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,5 +1,6 @@ """Dependances communes pour les routers""" from fastapi import Request +from sqlalchemy import text from .auth import decode_token from .database import SessionLocal @@ -18,3 +19,42 @@ def get_current_user(request: Request): if not token: return None return decode_token(token) + + +def get_user_perms(db, user): + """Charge les permissions depuis la base pour un user. + Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}""" + if not user: + return {} + uid = user.get("uid") + if not uid: + return {} + rows = db.execute(text( + "SELECT module, level FROM user_permissions WHERE user_id = :uid" + ), {"uid": uid}).fetchall() + return {r.module: r.level for r in rows} + + +def can_view(perms, module): + """L'utilisateur peut-il voir ce module ?""" + return module in perms + + +def can_edit(perms, module): + """L'utilisateur peut-il editer ce module ?""" + return perms.get(module) in ("edit", "admin") + + +def can_admin(perms, module): + """L'utilisateur a-t-il les droits admin sur ce module ?""" + return perms.get(module) == "admin" + + +def base_context(request, db, user): + """Context de base pour tous les templates (user + perms)""" + perms = get_user_perms(db, user) + return { + "request": request, + "user": user, + "perms": perms, + } diff --git a/app/main.py b/app/main.py index d7d917b..e9b06cf 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,33 @@ """PatchCenter v2 — Entry point FastAPI""" -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware from .config import APP_NAME, APP_VERSION +from .dependencies import get_current_user, get_user_perms +from .database import SessionLocal from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit + +class PermissionsMiddleware(BaseHTTPMiddleware): + """Injecte user + perms dans request.state pour tous les templates""" + async def dispatch(self, request: Request, call_next): + user = get_current_user(request) + perms = {} + if user: + db = SessionLocal() + try: + perms = get_user_perms(db, user) + finally: + db.close() + request.state.user = user + request.state.perms = perms + response = await call_next(request) + return response + + app = FastAPI(title=APP_NAME, version=APP_VERSION) +app.add_middleware(PermissionsMiddleware) app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(auth.router) @@ -20,10 +42,44 @@ app.include_router(audit.router) @app.get("/") -async def root(): +async def root(request: Request): + user = get_current_user(request) + if user: + return RedirectResponse(url="/dashboard") return RedirectResponse(url="/login") @app.get("/health") async def health(): return {"status": "ok", "app": APP_NAME, "version": APP_VERSION} + + +# --- Error handlers --- +from fastapi.templating import Jinja2Templates +_error_templates = Jinja2Templates(directory="app/templates") + +@app.exception_handler(500) +async def internal_error(request: Request, exc): + return _error_templates.TemplateResponse("error.html", { + "request": request, "code": 500, + "title": "Application en maintenance", + "message": "Une erreur interne est survenue. L'équipe technique a été notifiée.", + }, status_code=500) + +@app.exception_handler(404) +async def not_found(request: Request, exc): + return _error_templates.TemplateResponse("error.html", { + "request": request, "code": 404, + "title": "Page introuvable", + "message": "La page demandée n'existe pas.", + }, status_code=404) + +@app.exception_handler(Exception) +async def generic_error(request: Request, exc): + import traceback + traceback.print_exc() + return _error_templates.TemplateResponse("error.html", { + "request": request, "code": 500, + "title": "Application en maintenance", + "message": "Une erreur interne est survenue. L'équipe technique a été notifiée.", + }, status_code=500) diff --git a/app/routers/audit.py b/app/routers/audit.py index a4c9d84..0fd1461 100644 --- a/app/routers/audit.py +++ b/app/routers/audit.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text -from ..dependencies import get_db, get_current_user +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..config import APP_NAME router = APIRouter() diff --git a/app/routers/auth.py b/app/routers/auth.py index 82cc0b9..2934fc5 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates from sqlalchemy import text from ..dependencies import get_db, get_current_user from ..auth import verify_password, create_access_token, hash_password +from ..services.audit_service import log_login, log_logout, log_login_failed from ..config import APP_NAME, APP_VERSION router = APIRouter() @@ -20,25 +21,35 @@ async def login(request: Request, username: str = Form(...), password: str = For row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"), {"u": username}).fetchone() if not row: + log_login_failed(db, request, username) + db.commit() return templates.TemplateResponse("login.html", { "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu" }) - # Verifier mot de passe (bcrypt pour web, PBKDF2 legacy pour SLPM) try: ok = verify_password(password, row.password_hash) except Exception: ok = False if not ok: + log_login_failed(db, request, username) + db.commit() return templates.TemplateResponse("login.html", { "request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect" }) token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id}) + user = {"sub": row.username, "role": row.role, "uid": row.id} + log_login(db, request, user) + db.commit() response = RedirectResponse(url="/dashboard", status_code=303) - response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax") + response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600) return response @router.get("/logout") -async def logout(): +async def logout(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if user: + log_logout(db, request, user) + db.commit() response = RedirectResponse(url="/login", status_code=302) response.delete_cookie("access_token") return response diff --git a/app/routers/campaigns.py b/app/routers/campaigns.py index 463eb7d..d57e131 100644 --- a/app/routers/campaigns.py +++ b/app/routers/campaigns.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text -from ..dependencies import get_db, get_current_user +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..services.campaign_service import ( list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats, create_campaign_from_planning, get_servers_for_planning, @@ -16,6 +16,11 @@ from ..services.campaign_service import ( ) from ..services.prereq_service import check_prereqs_campaign, check_single_prereq from ..services.secrets_service import get_secret +from ..services.audit_service import ( + log_campaign_create, log_campaign_status, log_campaign_delete, + log_session_exclude, log_session_assign, log_session_take, log_session_release, + log_prereq_check, +) from ..config import APP_NAME router = APIRouter() @@ -47,6 +52,10 @@ async def campaigns_list(request: Request, db=Depends(get_db), return RedirectResponse(url="/login") if not year: year = datetime.now().year + perms = get_user_perms(db, user) + if not can_view(perms, "campaigns"): + return RedirectResponse(url="/dashboard") + campaigns = list_campaigns(db, year=year, status=status) now = datetime.now() @@ -62,11 +71,13 @@ async def campaigns_list(request: Request, db=Depends(get_db), ORDER BY pp.week_number """), {"y": year, "cw": current_week}).fetchall() - return templates.TemplateResponse("campaigns.html", { - "request": request, "user": user, "app_name": APP_NAME, + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "campaigns": campaigns, "year": year, "status_filter": status, "planned_weeks": planned_weeks, }) + return templates.TemplateResponse("campaigns.html", ctx) @router.get("/campaigns/preview", response_class=HTMLResponse) @@ -96,9 +107,19 @@ async def campaign_create(request: Request, db=Depends(get_db)): for key in form.keys(): if key.startswith("exclude_"): excluded.append(int(key.replace("exclude_", ""))) - cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) + try: + cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded) + except Exception as e: + db.rollback() + err = str(e) + if "unique" in err.lower() or "duplicate" in err.lower(): + return RedirectResponse(url=f"/campaigns?year={year}&msg=already_exists", status_code=303) + import traceback; traceback.print_exc() + return RedirectResponse(url=f"/campaigns?year={year}&msg=create_error", status_code=303) if not cid: return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303) + log_campaign_create(db, request, user, cid, label) + db.commit() return RedirectResponse(url=f"/campaigns/{cid}", status_code=303) @@ -113,25 +134,35 @@ async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db) sessions = get_campaign_sessions(db, campaign_id) stats = get_campaign_stats(db, campaign_id) prereq = get_prereq_stats(db, campaign_id) - can_plan = can_plan_campaign(db, campaign_id) - role = user.get("role", "viewer") - is_coordinator = role in ("admin", "coordinator") - max_srv = _get_max_servers(db) + can_plan_flag = can_plan_campaign(db, campaign_id) + perms = get_user_perms(db, user) intervenants = db.execute(text( - "SELECT id, display_name FROM users WHERE is_active = true ORDER BY display_name" + "SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name" )).fetchall() op_limits = get_campaign_operator_limits(db, campaign_id) - return templates.TemplateResponse("campaign_detail.html", { - "request": request, "user": user, "app_name": APP_NAME, + # Compteur par operateur + op_counts = db.execute(text(""" + SELECT u.display_name, COUNT(*) as count + FROM patch_sessions ps + JOIN users u ON ps.intervenant_id = u.id + WHERE ps.campaign_id = :cid AND ps.status NOT IN ('excluded','cancelled') + GROUP BY u.display_name ORDER BY count DESC + """), {"cid": campaign_id}).fetchall() + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "c": campaign, "sessions": sessions, "stats": stats, - "prereq": prereq, "can_plan": can_plan, + "prereq": prereq, "can_plan": can_plan_flag, "exclusion_reasons": EXCLUSION_REASONS, - "is_coordinator": is_coordinator, "intervenants": intervenants, - "op_limits": op_limits, + "can_edit_campaigns": can_edit(perms, "campaigns"), + "can_admin_campaigns": can_admin(perms, "campaigns"), + "intervenants": intervenants, "op_limits": op_limits, "op_counts": op_counts, "msg": request.query_params.get("msg"), }) + return templates.TemplateResponse("campaign_detail.html", ctx) @router.post("/campaigns/{campaign_id}/status") @@ -140,12 +171,40 @@ async def campaign_status_change(request: Request, campaign_id: int, user = get_current_user(request) if not user: return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns"): + return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=forbidden", status_code=303) if new_status == "planned" and not can_plan_campaign(db, campaign_id): return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303) + campaign = get_campaign(db, campaign_id) + old_status = campaign.status if campaign else "unknown" update_campaign_status(db, campaign_id, new_status) + log_campaign_status(db, request, user, campaign_id, old_status, new_status) + db.commit() return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303) +@router.post("/campaigns/{campaign_id}/delete") +async def campaign_delete(request: Request, campaign_id: int, db=Depends(get_db)): + """Supprime completement une campagne (admin/coordinateur)""" + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns"): + return RedirectResponse(url="/campaigns", status_code=303) + campaign = get_campaign(db, campaign_id) + if not campaign: + return RedirectResponse(url="/campaigns", status_code=303) + year = campaign.year + # Supprimer sessions puis campagne + db.execute(text("DELETE FROM campaign_operator_limits WHERE campaign_id = :cid"), {"cid": campaign_id}) + db.execute(text("DELETE FROM patch_sessions WHERE campaign_id = :cid"), {"cid": campaign_id}) + db.execute(text("DELETE FROM campaigns WHERE id = :cid"), {"cid": campaign_id}) + db.commit() + return RedirectResponse(url=f"/campaigns?year={year}&msg=deleted", status_code=303) + + @router.post("/campaigns/session/{session_id}/prereq") async def session_prereq(request: Request, session_id: int, db=Depends(get_db), prereq_ssh: str = Form(...), prereq_satellite: str = Form(...), @@ -157,7 +216,7 @@ async def session_prereq(request: Request, session_id: int, db=Depends(get_db), rollback_method or None, rollback_justif, user.get("sub")) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_saved", status_code=303) @router.post("/campaigns/{campaign_id}/check-prereqs") @@ -166,6 +225,8 @@ async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends( if not user: return RedirectResponse(url="/login") checked, auto_excluded = check_prereqs_campaign(db, campaign_id) + log_prereq_check(db, request, user, campaign_id, checked, auto_excluded) + db.commit() return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}", status_code=303) @@ -177,7 +238,7 @@ async def session_check_prereq(request: Request, session_id: int, db=Depends(get check_single_prereq(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=prereq_checked", status_code=303) @router.post("/campaigns/session/{session_id}/exclude") @@ -189,7 +250,7 @@ async def session_exclude(request: Request, session_id: int, db=Depends(get_db), exclude_session(db, session_id, reason, detail, user.get("sub")) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=excluded", status_code=303) @router.post("/campaigns/session/{session_id}/restore") @@ -200,7 +261,7 @@ async def session_restore(request: Request, session_id: int, db=Depends(get_db)) restore_session(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=restored", status_code=303) # --- Assignation operateurs --- @@ -222,7 +283,7 @@ async def session_take(request: Request, session_id: int, db=Depends(get_db)): if current >= limit: return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=limit_reached", status_code=303) assign_operator(db, session_id, user.get("uid")) - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=taken", status_code=303) @router.post("/campaigns/session/{session_id}/release") @@ -238,7 +299,7 @@ async def session_release(request: Request, session_id: int, db=Depends(get_db)) unassign_operator(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=released", status_code=303) @router.post("/campaigns/session/{session_id}/assign") @@ -256,7 +317,7 @@ async def session_assign(request: Request, session_id: int, db=Depends(get_db), unassign_operator(db, session_id) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=assigned", status_code=303) # --- Limites operateurs par campagne --- @@ -272,6 +333,71 @@ async def set_op_limit(request: Request, campaign_id: int, db=Depends(get_db), return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=limit_set", status_code=303) +# --- Assignations par defaut --- + +@router.get("/assignments", response_class=HTMLResponse) +async def assignments_page(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "campaigns"): + return RedirectResponse(url="/campaigns") + + rules = db.execute(text(""" + SELECT da.*, u.display_name FROM default_assignments da + JOIN users u ON da.user_id = u.id ORDER BY da.priority, da.rule_type + """)).fetchall() + operators = db.execute(text( + "SELECT id, display_name FROM users WHERE is_active = true AND role = 'operator' ORDER BY display_name" + )).fetchall() + domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall() + zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall() + app_types = db.execute(text( + "SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type" + )).fetchall() + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "rules": rules, "intervenants": operators, + "domains": domains, "zones": zones, + "app_types": [r.app_type for r in app_types], + "msg": request.query_params.get("msg"), + }) + return templates.TemplateResponse("assignments.html", ctx) + + +@router.post("/assignments/add") +async def assignment_add(request: Request, db=Depends(get_db), + rule_type: str = Form(...), rule_value: str = Form(...), + user_id: int = Form(...), priority: int = Form(10), + note: str = Form("")): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + try: + db.execute(text(""" + INSERT INTO default_assignments (rule_type, rule_value, user_id, priority, note) + VALUES (:rt, :rv, :uid, :p, :n) + """), {"rt": rule_type, "rv": rule_value.strip(), "uid": user_id, + "p": priority, "n": note or None}) + db.commit() + return RedirectResponse(url="/assignments?msg=added", status_code=303) + except Exception: + db.rollback() + return RedirectResponse(url="/assignments?msg=error", status_code=303) + + +@router.post("/assignments/{rule_id}/delete") +async def assignment_delete(request: Request, rule_id: int, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + db.execute(text("DELETE FROM default_assignments WHERE id = :id"), {"id": rule_id}) + db.commit() + return RedirectResponse(url="/assignments?msg=deleted", status_code=303) + + @router.post("/campaigns/session/{session_id}/schedule") async def session_schedule(request: Request, session_id: int, db=Depends(get_db), date_prevue: str = Form(""), heure_prevue: str = Form("")): @@ -282,4 +408,4 @@ async def session_schedule(request: Request, session_id: int, db=Depends(get_db) update_session_schedule(db, session_id, date_prevue or None, heure_prevue or None) row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"), {"id": session_id}).fetchone() - return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled#row-{session_id}", status_code=303) + return RedirectResponse(url=f"/campaigns/{row.campaign_id}?msg=scheduled", status_code=303) diff --git a/app/routers/planning.py b/app/routers/planning.py index 31aacf0..fe61781 100644 --- a/app/routers/planning.py +++ b/app/routers/planning.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text -from ..dependencies import get_db, get_current_user +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..config import APP_NAME router = APIRouter() @@ -83,8 +83,9 @@ async def planning_page(request: Request, db=Depends(get_db), if next_week > 53: next_week = 1 + perms = get_user_perms(db, user) return templates.TemplateResponse("planning.html", { - "request": request, "user": user, "app_name": APP_NAME, + "request": request, "user": user, "perms": perms, "app_name": APP_NAME, "year": year, "domains": domains, "grid": grid, "freeze_weeks": freeze_weeks, "months": MONTHS, "domain_colors": DOMAIN_COLORS, "weeks": range(1, 54), diff --git a/app/routers/specifics.py b/app/routers/specifics.py index 38643b2..10fb739 100644 --- a/app/routers/specifics.py +++ b/app/routers/specifics.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Request, Depends, Query, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text -from ..dependencies import get_db, get_current_user +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..config import APP_NAME router = APIRouter() diff --git a/app/routers/users.py b/app/routers/users.py index f38de91..f381ac9 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -3,14 +3,14 @@ from fastapi import APIRouter, Request, Depends, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text -from ..dependencies import get_db, get_current_user +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context from ..auth import hash_password from ..config import APP_NAME router = APIRouter() templates = Jinja2Templates(directory="app/templates") -MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users"] +MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"] LEVELS = ["view", "edit", "admin"] @@ -30,44 +30,67 @@ def _get_users_with_perms(db): return result -@router.get("/users", response_class=HTMLResponse) -async def users_page(request: Request, db=Depends(get_db)): +def _check_access(request, db): user = get_current_user(request) if not user: - return RedirectResponse(url="/login") + return None, None, RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_view(perms, "users"): + return None, None, RedirectResponse(url="/dashboard") + return user, perms, None + + +@router.get("/users", response_class=HTMLResponse) +async def users_page(request: Request, db=Depends(get_db)): + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect users_data = _get_users_with_perms(db) - return templates.TemplateResponse("users.html", { - "request": request, "user": user, "app_name": APP_NAME, - "users_data": users_data, "modules": MODULES, "levels": LEVELS, - "saved": None, + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "users_data": users_data, + "modules": MODULES, "levels": LEVELS, + "can_edit_users": can_edit(perms, "users"), + "msg": request.query_params.get("msg"), }) + return templates.TemplateResponse("users.html", ctx) -@router.post("/users/add", response_class=HTMLResponse) +@router.post("/users/add") async def user_add(request: Request, db=Depends(get_db), new_username: str = Form(...), new_display_name: str = Form(...), new_email: str = Form(""), new_password: str = Form(...), new_role: str = Form("operator")): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_edit(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) + + # Verifier si username existe deja + existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"), + {"u": new_username.strip()}).fetchone() + if existing: + if not existing.is_active: + return RedirectResponse(url=f"/users?msg=exists_inactive", status_code=303) + return RedirectResponse(url=f"/users?msg=exists", status_code=303) pw_hash = hash_password(new_password) db.execute(text(""" INSERT INTO users (username, display_name, email, password_hash, role) VALUES (:u, :dn, :e, :ph, :r) - """), {"u": new_username, "dn": new_display_name, "e": new_email or None, + """), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None, "ph": pw_hash, "r": new_role}) - # Recuperer l'id du nouveau user - row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username}).fetchone() + row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone() if row: - # Permissions par defaut selon role default_perms = { "admin": {m: "admin" for m in MODULES}, - "coordinator": {"servers": "edit", "campaigns": "admin", "qualys": "edit", "audit": "view", "settings": "view", "users": "view"}, - "operator": {"servers": "edit", "campaigns": "edit", "qualys": "view", "audit": "view"}, - "viewer": {"servers": "view", "campaigns": "view", "qualys": "view", "audit": "view"}, + "coordinator": {"servers": "admin", "campaigns": "admin", "qualys": "admin", "audit": "admin", + "settings": "view", "users": "view", "planning": "admin", "specifics": "admin"}, + "operator": {"servers": "admin", "campaigns": "view", "qualys": "admin", "audit": "admin", + "settings": "view", "planning": "view", "specifics": "admin"}, + "viewer": {"servers": "view", "campaigns": "view", "audit": "view", "planning": "view"}, } for mod, lvl in default_perms.get(new_role, {}).items(): db.execute(text( @@ -75,24 +98,19 @@ async def user_add(request: Request, db=Depends(get_db), ), {"uid": row.id, "m": mod, "l": lvl}) db.commit() - users_data = _get_users_with_perms(db) - return templates.TemplateResponse("users.html", { - "request": request, "user": user, "app_name": APP_NAME, - "users_data": users_data, "modules": MODULES, "levels": LEVELS, - "saved": "add", - }) + return RedirectResponse(url="/users?msg=added", status_code=303) -@router.post("/users/{user_id}/permissions", response_class=HTMLResponse) +@router.post("/users/{user_id}/permissions") async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_edit(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) form = await request.form() - # Supprimer les anciennes permissions db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id}) - # Inserer les nouvelles for mod in MODULES: lvl = form.get(f"perm_{mod}", "") if lvl and lvl in LEVELS: @@ -100,43 +118,73 @@ async def user_permissions_save(request: Request, user_id: int, db=Depends(get_d "INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)" ), {"uid": user_id, "m": mod, "l": lvl}) db.commit() - - users_data = _get_users_with_perms(db) - return templates.TemplateResponse("users.html", { - "request": request, "user": user, "app_name": APP_NAME, - "users_data": users_data, "modules": MODULES, "levels": LEVELS, - "saved": f"perms_{user_id}", - }) + return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303) -@router.post("/users/{user_id}/toggle", response_class=HTMLResponse) +@router.post("/users/{user_id}/edit") +async def user_edit(request: Request, user_id: int, db=Depends(get_db), + display_name: str = Form(""), email: str = Form(""), + role: str = Form("")): + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_edit(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) + + updates = [] + params = {"id": user_id} + if display_name: + updates.append("display_name = :dn"); params["dn"] = display_name + if email: + updates.append("email = :em"); params["em"] = email + if role: + updates.append("role = :r"); params["r"] = role + if updates: + db.execute(text(f"UPDATE users SET {', '.join(updates)} WHERE id = :id"), params) + db.commit() + return RedirectResponse(url="/users?msg=edited", status_code=303) + + +@router.post("/users/{user_id}/toggle") async def user_toggle(request: Request, user_id: int, db=Depends(get_db)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_edit(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) + # Empecher de se desactiver soi-meme + if user_id == user.get("uid"): + return RedirectResponse(url="/users?msg=cant_self", status_code=303) db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id}) db.commit() - users_data = _get_users_with_perms(db) - return templates.TemplateResponse("users.html", { - "request": request, "user": user, "app_name": APP_NAME, - "users_data": users_data, "modules": MODULES, "levels": LEVELS, - "saved": "toggle", - }) + return RedirectResponse(url="/users?msg=toggled", status_code=303) -@router.post("/users/{user_id}/password", response_class=HTMLResponse) +@router.post("/users/{user_id}/password") async def user_password(request: Request, user_id: int, db=Depends(get_db), new_password: str = Form(...)): - user = get_current_user(request) - if not user: - return RedirectResponse(url="/login") + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_edit(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) pw_hash = hash_password(new_password) db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"), {"ph": pw_hash, "id": user_id}) db.commit() - users_data = _get_users_with_perms(db) - return templates.TemplateResponse("users.html", { - "request": request, "user": user, "app_name": APP_NAME, - "users_data": users_data, "modules": MODULES, "levels": LEVELS, - "saved": "password", - }) + return RedirectResponse(url="/users?msg=password_changed", status_code=303) + + +@router.post("/users/{user_id}/delete") +async def user_delete(request: Request, user_id: int, db=Depends(get_db)): + user, perms, redirect = _check_access(request, db) + if redirect: + return redirect + if not can_admin(perms, "users"): + return RedirectResponse(url="/users?msg=forbidden", status_code=303) + if user_id == user.get("uid"): + return RedirectResponse(url="/users?msg=cant_self", status_code=303) + db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id}) + db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + db.commit() + return RedirectResponse(url="/users?msg=deleted", status_code=303) diff --git a/app/services/audit_service.py b/app/services/audit_service.py new file mode 100644 index 0000000..8ac173b --- /dev/null +++ b/app/services/audit_service.py @@ -0,0 +1,139 @@ +"""Service audit — log centralise de toutes les actions pour Splunk""" +import json +import logging +from datetime import datetime +from fastapi import Request +from sqlalchemy import text + +logger = logging.getLogger("patchcenter.audit") + +# Format JSON structure pour Splunk (une ligne par event) +LOG_FORMAT = '%(message)s' +handler = logging.FileHandler("/var/log/patchcenter_audit.json") +handler.setFormatter(logging.Formatter(LOG_FORMAT)) +logger.addHandler(handler) +logger.setLevel(logging.INFO) + + +def log_action(db, request: Request, user: dict, action: str, + entity_type: str = None, entity_id: int = None, + details: dict = None): + """Log une action dans la base ET dans le fichier JSON pour Splunk""" + username = user.get("sub", "system") if user else "system" + uid = user.get("uid") if user else None + ip = _get_client_ip(request) if request else None + + # Insert en base + db.execute(text(""" + INSERT INTO audit_log (user_id, username, action, entity_type, entity_id, details, ip_address) + VALUES (:uid, :un, :action, :et, :eid, :details, :ip) + """), { + "uid": uid, "un": username, "action": action, + "et": entity_type, "eid": entity_id, + "details": json.dumps(details) if details else None, + "ip": ip, + }) + + # Log fichier JSON (Splunk-ready) + event = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "app": "patchcenter", + "action": action, + "username": username, + "user_id": uid, + "entity_type": entity_type, + "entity_id": entity_id, + "ip": ip, + "details": details, + } + logger.info(json.dumps(event, ensure_ascii=False)) + + +def _get_client_ip(request: Request): + """Extrait l'IP client (supporte X-Forwarded-For derriere nginx)""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return None + + +# === Actions predefinies === + +def log_login(db, request, user): + log_action(db, request, user, "LOGIN", "user", user.get("uid")) + +def log_logout(db, request, user): + log_action(db, request, user, "LOGOUT", "user", user.get("uid")) + +def log_login_failed(db, request, username): + log_action(db, request, None, "LOGIN_FAILED", "user", None, + {"username": username}) + +def log_campaign_create(db, request, user, campaign_id, label): + log_action(db, request, user, "CAMPAIGN_CREATE", "campaign", campaign_id, + {"label": label}) + +def log_campaign_status(db, request, user, campaign_id, old_status, new_status): + log_action(db, request, user, "CAMPAIGN_STATUS", "campaign", campaign_id, + {"old": old_status, "new": new_status}) + +def log_campaign_delete(db, request, user, campaign_id, label): + log_action(db, request, user, "CAMPAIGN_DELETE", "campaign", campaign_id, + {"label": label}) + +def log_session_exclude(db, request, user, session_id, hostname, reason): + log_action(db, request, user, "SESSION_EXCLUDE", "patch_session", session_id, + {"hostname": hostname, "reason": reason}) + +def log_session_assign(db, request, user, session_id, hostname, operator): + log_action(db, request, user, "SESSION_ASSIGN", "patch_session", session_id, + {"hostname": hostname, "operator": operator}) + +def log_session_take(db, request, user, session_id, hostname): + log_action(db, request, user, "SESSION_TAKE", "patch_session", session_id, + {"hostname": hostname}) + +def log_session_release(db, request, user, session_id, hostname): + log_action(db, request, user, "SESSION_RELEASE", "patch_session", session_id, + {"hostname": hostname}) + +def log_server_edit(db, request, user, server_id, hostname, changes): + log_action(db, request, user, "SERVER_EDIT", "server", server_id, + {"hostname": hostname, "changes": changes}) + +def log_prereq_check(db, request, user, campaign_id, checked, excluded): + log_action(db, request, user, "PREREQ_CHECK", "campaign", campaign_id, + {"checked": checked, "auto_excluded": excluded}) + +def log_user_create(db, request, user, new_user_id, new_username): + log_action(db, request, user, "USER_CREATE", "user", new_user_id, + {"new_username": new_username}) + +def log_user_edit(db, request, user, target_user_id, changes): + log_action(db, request, user, "USER_EDIT", "user", target_user_id, + {"changes": changes}) + +def log_user_delete(db, request, user, target_user_id, username): + log_action(db, request, user, "USER_DELETE", "user", target_user_id, + {"deleted_username": username}) + +def log_user_toggle(db, request, user, target_user_id, new_state): + log_action(db, request, user, "USER_TOGGLE", "user", target_user_id, + {"active": new_state}) + +def log_permissions_change(db, request, user, target_user_id, perms): + log_action(db, request, user, "PERMISSIONS_CHANGE", "user", target_user_id, + {"permissions": perms}) + +def log_setting_change(db, request, user, section): + log_action(db, request, user, "SETTING_CHANGE", "settings", None, + {"section": section}) + +def log_planning_change(db, request, user, action_type, entry_id=None, details=None): + log_action(db, request, user, f"PLANNING_{action_type}", "planning", entry_id, details) + +def log_qualys_sync(db, request, user, server_id, hostname, result): + log_action(db, request, user, "QUALYS_SYNC", "server", server_id, + {"hostname": hostname, "result": result}) diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index 3c0b915..542d6cb 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -127,7 +127,7 @@ def get_servers_for_planning(db, year, week_number): servers = db.execute(text(f""" SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier, s.licence_support, s.ssh_method, s.machine_type, - s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id, + s.pref_patch_jour, s.pref_patch_heure, s.default_intervenant_id, s.app_group, d.name as domaine, d.code as domain_code, e.name as environnement FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id @@ -140,6 +140,29 @@ def get_servers_for_planning(db, year, week_number): return servers, planning +def _generate_slots(): + """Genere les creneaux horaires de la journee: 09h00-12h30 + 14h00-16h45 par pas de 15min""" + slots = [] + # Matin: 09h00 a 12h15 (dernier debut = 12h15, fin 12h30) + h, m = 9, 0 + while h < 12 or (h == 12 and m <= 15): + slots.append(f"{h:02d}h{m:02d}") + m += 15 + if m >= 60: + m = 0; h += 1 + # Apres-midi: 14h00 a 16h30 (dernier debut = 16h30, fin 16h45) + h, m = 14, 0 + while h < 16 or (h == 16 and m <= 30): + slots.append(f"{h:02d}h{m:02d}") + m += 15 + if m >= 60: + m = 0; h += 1 + return slots + + +DAILY_SLOTS = _generate_slots() # 27 slots par jour + + def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None): servers, planning = get_servers_for_planning(db, year, week_number) if not servers: @@ -147,7 +170,6 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude wc = f"S{week_number:02d}" lun, mar, mer, jeu = _week_dates(year, week_number) - p = planning[0] if planning else None row = db.execute(text(""" INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by) @@ -155,32 +177,90 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude RETURNING id """), {"wc": wc, "y": year, "label": label, "ds": lun, "de": jeu, "uid": user_id}).fetchone() cid = row.id - excluded = set(excluded_ids or []) - for s in servers: - status = 'excluded' if s.id in excluded else 'pending' - # Date par defaut : hors-prod = lun/mar, prod = mer/jeu - is_prod = (s.environnement == 'Production') + + # Separer hors-prod et prod + hprod_servers = [s for s in servers if s.environnement != 'Production' and s.id not in excluded] + prod_servers = [s for s in servers if s.environnement == 'Production' and s.id not in excluded] + excluded_servers = [s for s in servers if s.id in excluded] + + # Trier par app_group pour grouper les serveurs du meme applicatif + hprod_servers.sort(key=lambda s: (s.app_group or '', s.hostname)) + prod_servers.sort(key=lambda s: (s.app_group or '', s.hostname)) + + # Attribuer les creneaux + # Hors-prod: lundi + mardi + hprod_jours = [lun, mar] + slot_idx = 0 + jour_idx = 0 + for s in hprod_servers: + jour = hprod_jours[jour_idx] + heure = DAILY_SLOTS[slot_idx] + + # Preference serveur if s.pref_patch_jour and s.pref_patch_jour != 'indifferent': - jour_map = {"lundi": lun, "mardi": mar, "mercredi": mer, "jeudi": jeu} - date_prevue = jour_map.get(s.pref_patch_jour, mer if is_prod else lun) - else: - date_prevue = mer if is_prod else lun + jour_map = {"lundi": lun, "mardi": mar} + jour = jour_map.get(s.pref_patch_jour, jour) + if s.pref_patch_heure and s.pref_patch_heure != 'indifferent': + heure = s.pref_patch_heure - heure = s.pref_patch_heure if s.pref_patch_heure and s.pref_patch_heure != 'indifferent' else None - - # Auto-assigner le default intervenant si defini default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None forced = True if default_op else False db.execute(text(""" INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue, intervenant_id, forced_assignment, assigned_at) - VALUES (:cid, :sid, :st, :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END) + VALUES (:cid, :sid, 'pending', :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END) ON CONFLICT (campaign_id, server_id) DO NOTHING - """), {"cid": cid, "sid": s.id, "st": status, "dp": date_prevue, "hp": heure, + """), {"cid": cid, "sid": s.id, "dp": jour, "hp": heure, "oid": default_op, "forced": forced}) + slot_idx += 1 + if slot_idx >= len(DAILY_SLOTS): + slot_idx = 0 + jour_idx = min(jour_idx + 1, len(hprod_jours) - 1) + + # Prod: mercredi + jeudi + prod_jours = [mer, jeu] + slot_idx = 0 + jour_idx = 0 + for s in prod_servers: + jour = prod_jours[jour_idx] + heure = DAILY_SLOTS[slot_idx] + + if s.pref_patch_jour and s.pref_patch_jour != 'indifferent': + jour_map = {"mercredi": mer, "jeudi": jeu} + jour = jour_map.get(s.pref_patch_jour, jour) + if s.pref_patch_heure and s.pref_patch_heure != 'indifferent': + heure = s.pref_patch_heure + + default_op = s.default_intervenant_id if hasattr(s, 'default_intervenant_id') else None + forced = True if default_op else False + + db.execute(text(""" + INSERT INTO patch_sessions (campaign_id, server_id, status, date_prevue, heure_prevue, + intervenant_id, forced_assignment, assigned_at) + VALUES (:cid, :sid, 'pending', :dp, :hp, :oid, :forced, CASE WHEN :oid IS NOT NULL THEN now() END) + ON CONFLICT (campaign_id, server_id) DO NOTHING + """), {"cid": cid, "sid": s.id, "dp": jour, "hp": heure, + "oid": default_op, "forced": forced}) + + slot_idx += 1 + if slot_idx >= len(DAILY_SLOTS): + slot_idx = 0 + jour_idx = min(jour_idx + 1, len(prod_jours) - 1) + + # Exclus + for s in excluded_servers: + db.execute(text(""" + INSERT INTO patch_sessions (campaign_id, server_id, status) + VALUES (:cid, :sid, 'excluded') + ON CONFLICT (campaign_id, server_id) DO NOTHING + """), {"cid": cid, "sid": s.id}) + + # Appliquer les regles d'assignation par defaut puis propager par app_group + _apply_default_assignments(db, cid) + count = db.execute(text( "SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'" ), {"cid": cid}).scalar() @@ -191,6 +271,89 @@ def create_campaign_from_planning(db, year, week_number, label, user_id, exclude return cid +def _apply_default_assignments(db, campaign_id): + """Applique les regles d'assignation par defaut (table default_assignments). + Priorite: server > app_type > app_group > domain > zone""" + rules = db.execute(text(""" + SELECT da.rule_type, da.rule_value, da.user_id + FROM default_assignments da + JOIN users u ON da.user_id = u.id AND u.is_active = true + ORDER BY da.priority ASC, da.rule_type + """)).fetchall() + + for rule in rules: + if rule.rule_type == 'server': + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now() + FROM servers s + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND LOWER(s.hostname) = LOWER(:val) + AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value}) + + elif rule.rule_type == 'app_type': + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now() + FROM servers s + LEFT JOIN server_specifics ss ON ss.server_id = s.id + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND UPPER(ss.app_type) = UPPER(:val) + AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value}) + + elif rule.rule_type == 'app_group': + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now() + FROM servers s + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND s.app_group = :val + AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value}) + + elif rule.rule_type == 'domain': + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now() + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND d.code = :val + AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value}) + + elif rule.rule_type == 'zone': + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :uid, forced_assignment = true, assigned_at = now() + FROM servers s + LEFT JOIN zones z ON s.zone_id = z.id + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND z.name = :val + AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "uid": rule.user_id, "val": rule.rule_value}) + + # Ensuite propager par app_group (meme operateur pour recette+prod) + _auto_link_app_groups(db, campaign_id) + + +def _auto_link_app_groups(db, campaign_id): + """Propage les intervenants entre recette et prod du meme app_group""" + assigned = db.execute(text(""" + SELECT ps.intervenant_id, s.app_group + FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id + WHERE ps.campaign_id = :cid AND ps.intervenant_id IS NOT NULL AND s.app_group IS NOT NULL + GROUP BY ps.intervenant_id, s.app_group + """), {"cid": campaign_id}).fetchall() + + for a in assigned: + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :oid, assigned_at = now() + FROM servers s + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND s.app_group = :ag AND ps.intervenant_id IS NULL AND ps.status = 'pending' + """), {"cid": campaign_id, "oid": a.intervenant_id, "ag": a.app_group}) + + def exclude_session(db, session_id, reason, detail, username): db.execute(text(""" UPDATE patch_sessions SET @@ -225,12 +388,27 @@ def _recalc_total(db, session_id): def assign_operator(db, session_id, operator_id, forced=False): - """Assigne un operateur a un serveur""" + """Assigne un operateur a un serveur + auto-assigne le meme groupe applicatif""" db.execute(text(""" UPDATE patch_sessions SET intervenant_id = :oid, assigned_at = now(), forced_assignment = :forced WHERE id = :id """), {"id": session_id, "oid": operator_id, "forced": forced}) + + # Auto-assigner les serveurs du meme app_group dans cette campagne + row = db.execute(text(""" + SELECT ps.campaign_id, s.app_group FROM patch_sessions ps + JOIN servers s ON ps.server_id = s.id WHERE ps.id = :id + """), {"id": session_id}).fetchone() + if row and row.app_group: + db.execute(text(""" + UPDATE patch_sessions ps SET intervenant_id = :oid, assigned_at = now() + FROM servers s + WHERE ps.server_id = s.id AND ps.campaign_id = :cid + AND s.app_group = :ag AND ps.intervenant_id IS NULL + AND ps.status = 'pending' + """), {"cid": row.campaign_id, "oid": operator_id, "ag": row.app_group}) + db.commit() diff --git a/app/services/prereq_service.py b/app/services/prereq_service.py index 2744b6a..5d2a349 100644 --- a/app/services/prereq_service.py +++ b/app/services/prereq_service.py @@ -5,6 +5,9 @@ import paramiko import os from sqlalchemy import text +# Mode demo : tout passe OK sans verifier (hors reseau SANEF) +DEMO_MODE = True # Passer a False en production SANEF + # Seuils espace disque (Mo) DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur / DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log @@ -130,6 +133,25 @@ def _check_server(s): "eligible": True, "exclude_reason": None, "exclude_detail": None, } + # Mode demo : tout OK + if DEMO_MODE: + result["ssh"] = "ok" + result["satellite"] = "ok" if s.os_family == 'linux' else "na" + result["rollback"] = "snapshot" if s.machine_type == 'vm' else "na" + result["disk_root_mb"] = 5000 + result["disk_var_mb"] = 3000 + result["disk_ok"] = True + # Quand meme exclure les EOL et decom + if s.licence_support == 'eol': + result["eligible"] = False + result["exclude_reason"] = "eol" + result["exclude_detail"] = "Licence EOL" + elif s.etat != 'en_production': + result["eligible"] = False + result["exclude_reason"] = "non_patchable" + result["exclude_detail"] = f"Etat: {s.etat}" + return result + # 1. Eligibilite de base if s.licence_support == 'eol': result["eligible"] = False diff --git a/app/templates/assignments.html b/app/templates/assignments.html new file mode 100644 index 0000000..c764d7f --- /dev/null +++ b/app/templates/assignments.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} +{% block title %}Assignations par defaut{% endblock %} +{% block content %} +
+
+ ← Campagnes +

Assignations par defaut

+

Regles appliquees automatiquement a la creation de chaque campagne. Priorite : serveur > application > app_group > domaine > zone.

+
+
+ +{% if msg %} +
+ {% if msg == 'added' %}Regle ajoutee.{% elif msg == 'deleted' %}Regle supprimee.{% elif msg == 'error' %}Erreur (regle dupliquee ?).{% endif %} +
+{% endif %} + + +
+ + + + + + + + + + + {% for r in rules %} + + + + + + + + + {% endfor %} + {% if not rules %} + + {% endif %} + +
PrioriteTypeValeurIntervenantNoteActions
{{ r.priority }}{{ r.rule_type }}{{ r.rule_value }}{{ r.display_name }}{{ r.note or '' }} +
+ +
+
Aucune regle definie
+
+ + +
+

Ajouter une regle

+
+
+ + +
+
+ + + + {% for d in domains %}{% endfor %} + {% for z in zones %}{% endfor %} + {% for a in app_types %}{% endfor %} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/audit.html b/app/templates/audit.html index f7c0879..94ca19d 100644 --- a/app/templates/audit.html +++ b/app/templates/audit.html @@ -11,7 +11,7 @@
{{ stats.failed }}
-
Echoues
+
Échoués
{{ stats.disk_alerts }}
diff --git a/app/templates/base.html b/app/templates/base.html index c02ee87..a63eb6e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -53,15 +53,16 @@

v2.0 — SecOps

@@ -70,7 +71,7 @@
{{ user.sub }} {{ user.role }} - Deconnexion + Déconnexion
diff --git a/app/templates/campaign_detail.html b/app/templates/campaign_detail.html index 0f61f6f..6624664 100644 --- a/app/templates/campaign_detail.html +++ b/app/templates/campaign_detail.html @@ -15,7 +15,7 @@
- {% if is_coordinator %} + {% if can_edit_campaigns %} {% if c.status == 'draft' %} {% if can_plan %}
@@ -37,7 +37,11 @@ {% endif %} {% if c.status in ('draft', 'pending_validation', 'planned') %} -
+ + {% endif %} + {% if c.status in ('draft', 'cancelled') %} +
+
{% endif %} {% endif %}
@@ -45,7 +49,7 @@ {% if msg %}
- {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq verifie.{% elif msg == 'prereq_needed' %}Prereqs requis avant soumission COMEP.{% elif msg == 'taken' %}Serveur pris.{% elif msg == 'released' %}Serveur libere.{% elif msg == 'assigned' %}Operateur assigne.{% elif msg == 'scheduled' %}Planning ajuste.{% elif msg == 'limit_set' %}Limite operateur definie.{% elif msg == 'already_taken' %}Ce serveur est deja pris.{% elif msg == 'limit_reached' %}Limite de serveurs atteinte pour cette campagne.{% elif msg == 'forced_cant_release' %}Assignation forcee — seul le coordinateur peut modifier.{% elif msg.startswith('checked_') %}Verification: {{ msg.split('_')[1] }} verifies, {{ msg.split('_')[2] }} auto-exclus.{% endif %} + {% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restauré.{% elif msg == 'prereq_saved' %}Prérequis sauvegardés.{% elif msg == 'prereq_checked' %}Prérequis vérifié.{% elif msg == 'prereq_needed' %}Prérequis requis avant soumission COMEP.{% elif msg == 'taken' %}Serveur pris.{% elif msg == 'released' %}Serveur libéré.{% elif msg == 'assigned' %}Intervenant assigné.{% elif msg == 'scheduled' %}Planning ajusté.{% elif msg == 'limit_set' %}Limite intervenant définie.{% elif msg == 'already_taken' %}Ce serveur est déjà pris.{% elif msg == 'limit_reached' %}Limite de serveurs atteinte pour cette campagne.{% elif msg == 'forced_cant_release' %}Assignation forcée — seul le coordinateur peut modifier.{% elif msg.startswith('checked_') %}Vérification: {{ msg.split('_')[1] }} vérifiés, {{ msg.split('_')[2] }} auto-exclus.{% endif %}
{% endif %} @@ -53,10 +57,10 @@
{{ stats.total }}
Total
{{ stats.patched }}
Patches
-
{{ stats.failed }}
Echoues
+
{{ stats.failed }}
Échoués
{{ stats.pending }}
En attente
{{ stats.excluded }}
Exclus
-
{{ stats.assigned }}
Assignes
+
{{ stats.assignéd }}
Assignés
{{ stats.unassigned }}
Libres
{% set patchable = stats.total - stats.excluded - stats.cancelled %} @@ -65,17 +69,35 @@
- -{% if c.status == 'draft' and prereq and is_coordinator %} + +{% if op_counts %} +
+ {% for oc in op_counts %} +
+ {{ oc.display_name }} + {{ oc.count }} +
+ {% endfor %} + {% if stats.unassigned > 0 %} +
+ Non assignés + {{ stats.unassigned }} +
+ {% endif %} +
+{% endif %} + + +{% if c.status == 'draft' and prereq and can_edit_campaigns %}
-

Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)

+

Prérequisuis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)

- +
-
A verifier{{ prereq.prereq_todo }}
+
A vérifiér{{ prereq.prereq_todo }}
SSH{{ prereq.ssh_ok }}
Satellite{{ prereq.sat_ok }}
Rollback{{ prereq.rollback_ok }}
@@ -99,7 +121,7 @@ Tier Jour prevu Heure - Operateur + Intervenant {% if c.status == 'draft' %} SSH Sat @@ -120,7 +142,7 @@ {% if s.intervenant_name %} {{ s.intervenant_name }} - {% if s.forced_assignment %}🔒{% endif %} + {% if s.forced_assignment %}🔒{% endif %} {% else %}{% endif %} {% if c.status == 'draft' %} @@ -132,30 +154,30 @@ {{ s.status }} {% if s.exclusion_reason %}
- {% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %} + {% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prérequis KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %} {% if s.excluded_by %}({{ s.excluded_by }}){% endif %}
{% endif %} - {% if s.status == 'excluded' and is_coordinator %} + {% if s.status == 'excluded' and can_edit_campaigns %}
{% elif s.status == 'pending' %} {% if c.status == 'planned' %} - {# Operateur: prendre/liberer #} + {# Intervenant: prendre/liberer #} {% if not s.intervenant_id %}
{% elif s.intervenant_id == user.uid and not s.forced_assignment %}
{% endif %} {# Coordinateur: assigner + planifier #} - {% if is_coordinator %} + {% if can_edit_campaigns %} {% endif %} - {% elif c.status == 'draft' and is_coordinator %} + {% elif c.status == 'draft' and can_edit_campaigns %}
@@ -180,8 +202,8 @@
- + {% for u in intervenants %}{% endfor %} @@ -206,10 +228,10 @@
- -{% if is_coordinator and c.status in ('planned', 'pending_validation') %} + +{% if can_edit_campaigns and c.status in ('planned', 'pending_validation') %}
-

Limites operateurs pour cette campagne

+

Limites intervenants pour cette campagne

{% if op_limits %}
{% for ol in op_limits %} @@ -220,10 +242,10 @@ {% endfor %}
{% endif %} - +
- - {% for u in intervenants %}{% endfor %}
@@ -235,7 +257,7 @@
- +
{% endif %} diff --git a/app/templates/campaigns.html b/app/templates/campaigns.html index 97e1b16..1a6745c 100644 --- a/app/templates/campaigns.html +++ b/app/templates/campaigns.html @@ -9,6 +9,13 @@
+{% set msg = request.query_params.get('msg') %} +{% if msg %} +
+ {% if msg == 'deleted' %}Campagne supprimée.{% elif msg == 'already_exists' %}Une campagne existe déjà pour cette semaine. Supprimez-la d'abord.{% elif msg == 'no_servers' %}Aucun serveur éligible pour cette semaine.{% elif msg == 'create_error' %}Erreur à la création. Vérifiez les logs.{% endif %} +
+{% endif %} +
Toutes @@ -49,12 +56,13 @@ {% endif %}
- + +{% if perms.campaigns in ('edit', 'admin') %}
-

Creer depuis le planning

+

Créer depuis le planning

{% if planned_weeks %}
@@ -75,8 +83,9 @@
{% else %} -

Aucune semaine planifiee a venir pour {{ year }}. Verifiez le planning.

+

Aucune semaine planifiee a venir pour {{ year }}. Vérifiez le planning.

{% endif %}
+{% endif %} {% endblock %} diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..50ba703 --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,28 @@ + + + + + PatchCenter — Maintenance + + + +
+
{{ code }}
+

{{ title }}

+

{{ message }}

+
+

Contacter le Responsable SecOps

+

Retour à l'accueil

+
+
+ + diff --git a/app/templates/partials/campaign_preview.html b/app/templates/partials/campaign_preview.html index bebddca..79ebf6a 100644 --- a/app/templates/partials/campaign_preview.html +++ b/app/templates/partials/campaign_preview.html @@ -44,6 +44,6 @@

Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.

- + diff --git a/app/templates/partials/server_detail.html b/app/templates/partials/server_detail.html index aef5cdb..719d1a6 100644 --- a/app/templates/partials/server_detail.html +++ b/app/templates/partials/server_detail.html @@ -70,9 +70,9 @@

Patching

Owner OS{{ s.patch_os_owner }}
-
Frequence{{ s.patch_frequency }}
+
Fréquence{{ s.patch_frequency }}
Podman{{ 'Oui' if s.is_podman else 'Non' }}
-
Prevenance{{ 'Oui' if s.need_pct else 'Non' }}
+
Prévenance{{ 'Oui' if s.need_pct else 'Non' }}
Satellite{% if s.satellite_host %}{% if 'sat1' in s.satellite_host %}SAT1 (DMZ){% elif 'sat2' in s.satellite_host %}SAT2 (LAN){% else %}{{ s.satellite_host }}{% endif %}{% else %}N/A{% endif %}
@@ -82,7 +82,7 @@

Responsables

Responsable: {{ s.responsable_nom or '-' }}
-
Referent: {{ s.referent_nom or '-' }}
+
Référent: {{ s.referent_nom or '-' }}
@@ -112,7 +112,7 @@
- +
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html index 1b95436..21819db 100644 --- a/app/templates/partials/server_edit.html +++ b/app/templates/partials/server_edit.html @@ -1,6 +1,6 @@
-

Editer {{ s.hostname }}

+

Éditer {{ s.hostname }}

@@ -65,7 +65,7 @@
- +
diff --git a/app/templates/partials/specific_edit.html b/app/templates/partials/specific_edit.html index 82b2d4c..39d4740 100644 --- a/app/templates/partials/specific_edit.html +++ b/app/templates/partials/specific_edit.html @@ -79,7 +79,7 @@ - + diff --git a/app/templates/planning.html b/app/templates/planning.html index 9a67aba..46bf4b7 100644 --- a/app/templates/planning.html +++ b/app/templates/planning.html @@ -6,8 +6,8 @@
{{ year - 1 }} {{ year + 1 }} - - {% if entries %} + + {% if entries and perms.planning in ('edit', 'admin') %}
@@ -19,7 +19,7 @@ {% if msg %}
- {% if msg == 'add' %}Entree ajoutee.{% elif msg == 'edit' %}Entree modifiee.{% elif msg == 'delete' %}Entree supprimee.{% elif msg == 'duplicate' %}Planning duplique avec succes.{% elif msg == 'exists' %}L'annee cible contient deja des entrees. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numero de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entree ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passe (semaine deja ecoulee).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %} + {% if msg == 'add' %}Entrée ajoutée.{% elif msg == 'edit' %}Entrée modifiée.{% elif msg == 'delete' %}Entrée supprimée.{% elif msg == 'duplicate' %}Planning dupliqué avec succès.{% elif msg == 'exists' %}L'annee cible contient déjà des entrées. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numéro de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entrée ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passé (semaine déjà écoulée).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %}
{% endif %} @@ -137,7 +137,7 @@ Cycle Statut Note - Actions + {% if perms.planning in ('edit', 'admin') %}Actions{% endif %} {% for e in entries %} @@ -164,6 +164,7 @@ + {% if perms.planning in ('edit', 'admin') %} + {% endif %} {% endfor %}
+{% if perms.planning in ('edit', 'admin') %}

Ajouter une entree

@@ -240,4 +243,5 @@
+{% endif %} {% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 4ad16ba..94ebed6 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -5,7 +5,7 @@ {% if saved %}
- Section "{{ saved }}" sauvegardee. + Section "{{ saved }}" sauvegardée.
{% endif %} @@ -93,7 +93,7 @@
-

Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.

+

Pour les environnements recette sans cle SSH. Chaque opérateur peut configurer son propre compte.

{% if editable.ssh_pwd %}{% endif %} @@ -134,7 +134,7 @@ -

Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.

+

Auth keyboard-interactive. Chaque opérateur configure son propre compte CyberArk. MDP saisi en session.

{% if editable.ssh_psmp %}{% endif %} @@ -228,7 +228,7 @@ {% if vc.is_active %}
- +
{% endif %} @@ -298,7 +298,7 @@
- +

Envoie les evenements de patching vers Splunk via HEC.

diff --git a/app/templates/specifics.html b/app/templates/specifics.html index fd74cf5..5f3b161 100644 --- a/app/templates/specifics.html +++ b/app/templates/specifics.html @@ -8,7 +8,7 @@ {% set msg = request.query_params.get('msg') %} {% if msg %}
- {% if msg == 'saved' %}Specificites sauvegardees.{% elif msg == 'added' %}Serveur ajoute.{% elif msg == 'not_found' %}Hostname non trouve en base.{% elif msg == 'exists' %}Ce serveur a deja des specificites.{% endif %} + {% if msg == 'saved' %}Spécificités sauvegardées.{% elif msg == 'added' %}Serveur ajouté.{% elif msg == 'not_found' %}Hostname non trouvé en base.{% elif msg == 'exists' %}Ce serveur a déjà des spécificités.{% endif %}
{% endif %} diff --git a/app/templates/users.html b/app/templates/users.html index 8974421..f174288 100644 --- a/app/templates/users.html +++ b/app/templates/users.html @@ -3,29 +3,28 @@ {% block content %}

Utilisateurs & Permissions

-{% if saved %} -
- {% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %} +{% if msg %} +
+ {% if msg == 'added' %}Utilisateur créé.{% elif msg == 'edited' %}Utilisateur modifié.{% elif msg == 'password_changed' %}Mot de passe modifié.{% elif msg == 'toggled' %}Statut modifié.{% elif msg == 'perms_saved' %}Permissions sauvegardées.{% elif msg == 'deleted' %}Utilisateur supprimé.{% elif msg == 'exists' %}Ce nom d'utilisateur existe déjà.{% elif msg == 'exists_inactive' %}Ce nom existe déjà (désactivé). Réactivez-le plutôt.{% elif msg == 'cant_self' %}Vous ne pouvez pas vous désactiver/supprimer vous-même.{% elif msg == 'forbidden' %}Action non autorisée.{% endif %}
{% endif %} -
+
{% for ud in users_data %}
{{ ud.user.username }} {{ ud.user.display_name }} - {{ ud.user.role }} + {% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %} {{ 'Actif' if ud.user.is_active else 'Inactif' }} {% if ud.user.email %}{{ ud.user.email }}{% endif %}
- {% for m in modules %} {% if ud.perms.get(m) %} - {{ m[:3] }} + {{ m[:3] }} {% endif %} {% endfor %} @@ -33,15 +32,37 @@
+ {% if can_edit_users %} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

Permissions par module

-
+
{% for m in modules %}
- -
+
+ +
+ {% else %} +

Permissions en lecture seule

+ {% endif %}
{% endfor %}
+{% if can_edit_users %}

Ajouter un utilisateur

@@ -90,9 +115,9 @@
- +
-

Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.

- +

Permissions pre-remplies selon le role. Modifiables ensuite.

+
+{% endif %} {% endblock %}