Users/Contacts: workflow profils + LDAP + sync iTop + etat aligne
- Users: 4 profils (admin/coordinator/operator/viewer) remplacent la matrix - /users/add: picker contacts iTop (plus de creation libre) - /me/change-password: flow force_password_change - LDAP: service + section settings + option login - Sync iTop contacts: filtre par teams (SecOps/iPOP/Externe/DSI/Admin DSI) - Auto-desactivation users si contact inactif - etat: alignement sur enum iTop (production/implementation/stock/obsolete) - Menu: Contacts dans Administration, Serveurs en groupe repliable - Audit bases: demo/prod via JWT mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4fa5f67c32
commit
8479d7280e
@ -39,17 +39,30 @@ def get_current_user(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
def get_user_perms(db, user):
|
def get_user_perms(db, user):
|
||||||
"""Charge les permissions depuis la base pour un user.
|
"""Charge les permissions : profil (role) + overrides de user_permissions.
|
||||||
Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}"""
|
Retourne un dict {module: level} ex: {'servers': 'admin', 'campaigns': 'edit'}"""
|
||||||
if not user:
|
if not user:
|
||||||
return {}
|
return {}
|
||||||
uid = user.get("uid")
|
uid = user.get("uid")
|
||||||
if not uid:
|
if not uid:
|
||||||
return {}
|
return {}
|
||||||
rows = db.execute(text(
|
# Récupère le rôle
|
||||||
|
row = db.execute(text("SELECT role FROM users WHERE id = :uid"), {"uid": uid}).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
# Base : permissions du profil
|
||||||
|
from .services.profile_service import get_profile_perms
|
||||||
|
perms = get_profile_perms(row.role)
|
||||||
|
# Overrides éventuels depuis user_permissions (niveau plus élevé uniquement)
|
||||||
|
rank = {"view": 1, "edit": 2, "admin": 3}
|
||||||
|
overrides = db.execute(text(
|
||||||
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
||||||
), {"uid": uid}).fetchall()
|
), {"uid": uid}).fetchall()
|
||||||
return {r.module: r.level for r in rows}
|
for r in overrides:
|
||||||
|
cur = perms.get(r.module)
|
||||||
|
if not cur or rank.get(r.level, 0) > rank.get(cur, 0):
|
||||||
|
perms[r.module] = r.level
|
||||||
|
return perms
|
||||||
|
|
||||||
|
|
||||||
def can_view(perms, module):
|
def can_view(perms, module):
|
||||||
|
|||||||
30
app/main.py
30
app/main.py
@ -5,23 +5,42 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from .config import APP_NAME, APP_VERSION
|
from .config import APP_NAME, APP_VERSION
|
||||||
from .dependencies import get_current_user, get_user_perms
|
from .dependencies import get_current_user, get_user_perms
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal, SessionLocalDemo
|
||||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
|
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||||
"""Injecte user + perms dans request.state pour tous les templates"""
|
"""Injecte user + perms dans request.state pour tous les templates.
|
||||||
|
Gère aussi la redirection si force_password_change est activé."""
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
perms = {}
|
perms = {}
|
||||||
|
must_change_pwd = False
|
||||||
if user:
|
if user:
|
||||||
db = SessionLocal()
|
# Sélectionner la base selon le mode JWT (prod/demo)
|
||||||
|
factory = SessionLocalDemo if user.get("mode") == "demo" else SessionLocal
|
||||||
|
db = factory()
|
||||||
try:
|
try:
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
|
# Check force_password_change
|
||||||
|
from sqlalchemy import text
|
||||||
|
row = db.execute(text("SELECT force_password_change FROM users WHERE id=:uid"),
|
||||||
|
{"uid": user.get("uid")}).fetchone()
|
||||||
|
if row and row.force_password_change:
|
||||||
|
must_change_pwd = True
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
request.state.user = user
|
request.state.user = user
|
||||||
request.state.perms = perms
|
request.state.perms = perms
|
||||||
|
request.state.must_change_pwd = must_change_pwd
|
||||||
|
|
||||||
|
# Redirect vers change-password si forcé (sauf pour les routes de changement/logout/static)
|
||||||
|
if must_change_pwd and user:
|
||||||
|
allowed = ("/me/change-password", "/logout", "/static/")
|
||||||
|
if not any(request.url.path.startswith(p) for p in allowed):
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse(url="/me/change-password", status_code=303)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@ -41,10 +60,9 @@ app.include_router(specifics.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(contacts.router)
|
app.include_router(contacts.router)
|
||||||
app.include_router(qualys.router)
|
app.include_router(qualys.router)
|
||||||
app.include_router(safe_patching.router)
|
|
||||||
app.include_router(audit_full.router)
|
|
||||||
app.include_router(quickwin.router)
|
app.include_router(quickwin.router)
|
||||||
app.include_router(referentiel.router)
|
app.include_router(referentiel.router)
|
||||||
|
app.include_router(patching.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from ..dependencies import get_current_user
|
|||||||
from ..database import SessionLocal, SessionLocalDemo
|
from ..database import SessionLocal, SessionLocalDemo
|
||||||
from ..auth import verify_password, create_access_token, hash_password
|
from ..auth import verify_password, create_access_token, hash_password
|
||||||
from ..services.audit_service import log_login, log_logout, log_login_failed
|
from ..services.audit_service import log_login, log_logout, log_login_failed
|
||||||
|
from ..services.ldap_service import is_enabled as ldap_enabled, authenticate as ldap_authenticate
|
||||||
from ..config import APP_NAME, APP_VERSION
|
from ..config import APP_NAME, APP_VERSION
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -13,41 +14,62 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ldap_ok = ldap_enabled(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
return templates.TemplateResponse("login.html", {
|
return templates.TemplateResponse("login.html", {
|
||||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": None
|
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
|
||||||
|
"error": None, "ldap_enabled": ldap_ok,
|
||||||
})
|
})
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(request: Request, username: str = Form(...), password: str = Form(...),
|
async def login(request: Request, username: str = Form(...), password: str = Form(...),
|
||||||
mode: str = Form("reel")):
|
mode: str = Form("reel"), auth_method: str = Form("local")):
|
||||||
# Select DB based on mode
|
# Select DB based on mode
|
||||||
factory = SessionLocalDemo if mode == "demo" else SessionLocal
|
factory = SessionLocalDemo if mode == "demo" else SessionLocal
|
||||||
db = factory()
|
db = factory()
|
||||||
try:
|
try:
|
||||||
row = db.execute(text("SELECT id, username, password_hash, role, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
row = db.execute(text("SELECT id, username, password_hash, role, is_active, auth_type FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||||
{"u": username}).fetchone()
|
{"u": username}).fetchone()
|
||||||
|
|
||||||
|
ldap_is_on = ldap_enabled(db)
|
||||||
|
err_template = lambda msg: templates.TemplateResponse("login.html", {
|
||||||
|
"request": request, "app_name": APP_NAME, "version": APP_VERSION,
|
||||||
|
"error": msg, "ldap_enabled": ldap_is_on
|
||||||
|
})
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
log_login_failed(db, request, username)
|
log_login_failed(db, request, username)
|
||||||
db.commit()
|
db.commit()
|
||||||
return templates.TemplateResponse("login.html", {
|
return err_template("Utilisateur inconnu")
|
||||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
|
|
||||||
})
|
|
||||||
if not row.is_active:
|
if not row.is_active:
|
||||||
log_login_failed(db, request, username)
|
log_login_failed(db, request, username)
|
||||||
db.commit()
|
db.commit()
|
||||||
return templates.TemplateResponse("login.html", {
|
return err_template("Compte desactive")
|
||||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
|
|
||||||
})
|
# Choix de la methode d'auth
|
||||||
try:
|
use_ldap = (auth_method == "ldap") or (row.auth_type == "ldap")
|
||||||
ok = verify_password(password, row.password_hash)
|
if use_ldap and not ldap_is_on:
|
||||||
except Exception:
|
return err_template("LDAP non active")
|
||||||
ok = False
|
|
||||||
if not ok:
|
if use_ldap:
|
||||||
log_login_failed(db, request, username)
|
result = ldap_authenticate(db, username, password)
|
||||||
db.commit()
|
if not result.get("ok"):
|
||||||
return templates.TemplateResponse("login.html", {
|
log_login_failed(db, request, username)
|
||||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
|
db.commit()
|
||||||
})
|
return err_template(result.get("msg") or "Authentification LDAP echouee")
|
||||||
|
ok = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ok = verify_password(password, row.password_hash or "")
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
log_login_failed(db, request, username)
|
||||||
|
db.commit()
|
||||||
|
return err_template("Mot de passe incorrect")
|
||||||
# Include mode in JWT token
|
# Include mode in JWT token
|
||||||
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id, "mode": mode})
|
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id, "mode": mode})
|
||||||
user = {"sub": row.username, "role": row.role, "uid": row.id, "mode": mode}
|
user = {"sub": row.username, "role": row.role, "uid": row.id, "mode": mode}
|
||||||
|
|||||||
@ -75,6 +75,20 @@ SECTIONS = {
|
|||||||
("teams_sp_client_secret", "App Client Secret", True),
|
("teams_sp_client_secret", "App Client Secret", True),
|
||||||
("teams_sp_tenant_id", "Tenant ID", False),
|
("teams_sp_tenant_id", "Tenant ID", False),
|
||||||
],
|
],
|
||||||
|
"ldap": [
|
||||||
|
("ldap_enabled", "Activer LDAP/AD (true/false)", False),
|
||||||
|
("ldap_server", "Serveur (ex: ldaps://ad.sanef.com:636)", False),
|
||||||
|
("ldap_base_dn", "Base DN (ex: DC=sanef,DC=com)", False),
|
||||||
|
("ldap_bind_dn", "Compte de bind (DN complet)", False),
|
||||||
|
("ldap_bind_pwd", "Mot de passe compte de bind", True),
|
||||||
|
("ldap_user_filter", "Filtre user (ex: (sAMAccountName={username}))", False),
|
||||||
|
("ldap_email_attr", "Attribut email (ex: mail)", False),
|
||||||
|
("ldap_name_attr", "Attribut nom affiché (ex: displayName)", False),
|
||||||
|
("ldap_tls", "TLS (true/false)", False),
|
||||||
|
],
|
||||||
|
"itop_contacts": [
|
||||||
|
("itop_contact_teams", "Teams iTop à synchroniser (séparées par ,)", False),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -102,6 +116,8 @@ SECTION_ACCESS = {
|
|||||||
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||||
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||||
"itop": {"visible": ["admin"], "editable": ["admin"]},
|
"itop": {"visible": ["admin"], "editable": ["admin"]},
|
||||||
|
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
|
||||||
|
"ldap": {"visible": ["admin"], "editable": ["admin"]},
|
||||||
"security": {"visible": ["admin"], "editable": ["admin"]},
|
"security": {"visible": ["admin"], "editable": ["admin"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +189,20 @@ async def settings_save(request: Request, section: str, db=Depends(get_db)):
|
|||||||
return templates.TemplateResponse("settings.html", ctx)
|
return templates.TemplateResponse("settings.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/ldap/test")
|
||||||
|
async def settings_ldap_test(request: Request, db=Depends(get_db)):
|
||||||
|
"""Teste la connexion LDAP avec le compte de bind."""
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not can_edit(perms, "settings"):
|
||||||
|
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||||
|
from ..services.ldap_service import test_connection
|
||||||
|
return JSONResponse(test_connection(db))
|
||||||
|
|
||||||
|
|
||||||
# --- vCenter CRUD ---
|
# --- vCenter CRUD ---
|
||||||
|
|
||||||
@router.post("/settings/vcenter/add", response_class=HTMLResponse)
|
@router.post("/settings/vcenter/add", response_class=HTMLResponse)
|
||||||
|
|||||||
@ -1,180 +1,273 @@
|
|||||||
"""Router users — gestion utilisateurs + permissions par module"""
|
"""Router users — gestion utilisateurs par profil + picker de contacts iTop.
|
||||||
|
|
||||||
|
Seul l'admin peut gérer les utilisateurs.
|
||||||
|
Les utilisateurs sont créés depuis les contacts synchronisés d'iTop (sauf admin local).
|
||||||
|
"""
|
||||||
from fastapi import APIRouter, Request, Depends, Form
|
from fastapi import APIRouter, Request, Depends, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context
|
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 ..auth import hash_password
|
||||||
|
from ..services.profile_service import PROFILES, PROFILE_LABELS, PROFILE_DESCRIPTIONS
|
||||||
from ..config import APP_NAME
|
from ..config import APP_NAME
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"]
|
# Profils disponibles (keys du PROFILES dict)
|
||||||
LEVELS = ["view", "edit", "admin"]
|
ROLES = ["admin", "coordinator", "operator", "viewer"]
|
||||||
|
|
||||||
|
|
||||||
def _get_users_with_perms(db):
|
def _get_users(db):
|
||||||
users = db.execute(text(
|
users = db.execute(text("""
|
||||||
"SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username"
|
SELECT u.id, u.username, u.display_name, u.email, u.role, u.auth_type,
|
||||||
)).fetchall()
|
u.is_active, u.last_login, u.itop_person_id, u.force_password_change,
|
||||||
result = []
|
c.team as contact_team, c.function as contact_function
|
||||||
for u in users:
|
FROM users u
|
||||||
perms = {}
|
LEFT JOIN contacts c ON c.itop_id = u.itop_person_id
|
||||||
rows = db.execute(text(
|
ORDER BY u.is_active DESC, u.username
|
||||||
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
""")).fetchall()
|
||||||
), {"uid": u.id}).fetchall()
|
return users
|
||||||
for r in rows:
|
|
||||||
perms[r.module] = r.level
|
|
||||||
result.append({"user": u, "perms": perms})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _check_access(request, db):
|
def _check_access(request, db, require_edit=False):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return None, None, RedirectResponse(url="/login")
|
return None, None, RedirectResponse(url="/login")
|
||||||
perms = get_user_perms(db, user)
|
perms = get_user_perms(db, user)
|
||||||
if not can_view(perms, "users"):
|
if not can_view(perms, "users"):
|
||||||
return None, None, RedirectResponse(url="/dashboard")
|
return None, None, RedirectResponse(url="/dashboard")
|
||||||
|
if require_edit and not can_edit(perms, "users"):
|
||||||
|
return None, None, RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||||
return user, perms, None
|
return user, perms, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/change-password", response_class=HTMLResponse)
|
||||||
|
async def me_change_password_page(request: Request, db=Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
return templates.TemplateResponse("change_password.html", {
|
||||||
|
"request": request, "app_name": APP_NAME, "user": user,
|
||||||
|
"error": None, "perms": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/change-password")
|
||||||
|
async def me_change_password(request: Request, db=Depends(get_db),
|
||||||
|
current_password: str = Form(...),
|
||||||
|
new_password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
from ..auth import verify_password
|
||||||
|
row = db.execute(text("SELECT id, password_hash FROM users WHERE id=:uid"),
|
||||||
|
{"uid": user.get("uid")}).fetchone()
|
||||||
|
if not row:
|
||||||
|
return RedirectResponse(url="/logout", status_code=303)
|
||||||
|
|
||||||
|
err = None
|
||||||
|
if not verify_password(current_password, row.password_hash or ""):
|
||||||
|
err = "Mot de passe actuel incorrect"
|
||||||
|
elif new_password != confirm_password:
|
||||||
|
err = "Les deux mots de passe ne correspondent pas"
|
||||||
|
elif len(new_password) < 8:
|
||||||
|
err = "Le nouveau mot de passe doit faire au moins 8 caractères"
|
||||||
|
elif new_password == current_password:
|
||||||
|
err = "Le nouveau mot de passe doit être différent de l'actuel"
|
||||||
|
|
||||||
|
if err:
|
||||||
|
return templates.TemplateResponse("change_password.html", {
|
||||||
|
"request": request, "app_name": APP_NAME, "user": user,
|
||||||
|
"error": err, "perms": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
pw_hash = hash_password(new_password)
|
||||||
|
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=false,
|
||||||
|
updated_at=NOW() WHERE id=:uid"""),
|
||||||
|
{"ph": pw_hash, "uid": user.get("uid")})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/dashboard?msg=password_changed", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_class=HTMLResponse)
|
@router.get("/users", response_class=HTMLResponse)
|
||||||
async def users_page(request: Request, db=Depends(get_db)):
|
async def users_page(request: Request, db=Depends(get_db)):
|
||||||
user, perms, redirect = _check_access(request, db)
|
user, perms, redirect = _check_access(request, db)
|
||||||
if redirect:
|
if redirect:
|
||||||
return redirect
|
return redirect
|
||||||
users_data = _get_users_with_perms(db)
|
|
||||||
|
users = _get_users(db)
|
||||||
|
|
||||||
|
# Compter les contacts disponibles pour ajout (pas encore users)
|
||||||
|
available_count = db.execute(text("""
|
||||||
|
SELECT COUNT(*) FROM contacts c
|
||||||
|
WHERE c.itop_id IS NOT NULL AND c.is_active = true
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
ctx = base_context(request, db, user)
|
ctx = base_context(request, db, user)
|
||||||
ctx.update({
|
ctx.update({
|
||||||
"app_name": APP_NAME, "users_data": users_data,
|
"app_name": APP_NAME, "users": users,
|
||||||
"modules": MODULES, "levels": LEVELS,
|
"available_count": available_count,
|
||||||
|
"roles": ROLES, "profile_labels": PROFILE_LABELS,
|
||||||
|
"profile_descriptions": PROFILE_DESCRIPTIONS,
|
||||||
"can_edit_users": can_edit(perms, "users"),
|
"can_edit_users": can_edit(perms, "users"),
|
||||||
|
"can_admin_users": can_admin(perms, "users"),
|
||||||
"msg": request.query_params.get("msg"),
|
"msg": request.query_params.get("msg"),
|
||||||
})
|
})
|
||||||
return templates.TemplateResponse("users.html", ctx)
|
return templates.TemplateResponse("users.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/add")
|
@router.get("/users/add", response_class=HTMLResponse)
|
||||||
async def user_add(request: Request, db=Depends(get_db),
|
async def users_add_page(request: Request, db=Depends(get_db),
|
||||||
new_username: str = Form(...), new_display_name: str = Form(...),
|
search: str = "", team: str = ""):
|
||||||
new_email: str = Form(""), new_password: str = Form(...),
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
new_role: str = Form("operator")):
|
|
||||||
user, perms, redirect = _check_access(request, db)
|
|
||||||
if redirect:
|
if redirect:
|
||||||
return redirect
|
return redirect
|
||||||
if not can_edit(perms, "users"):
|
|
||||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
|
||||||
|
|
||||||
# Verifier si username existe deja
|
# Lister les contacts iTop non-encore-users
|
||||||
existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
where = ["c.itop_id IS NOT NULL", "c.is_active = true",
|
||||||
{"u": new_username.strip()}).fetchone()
|
"NOT EXISTS (SELECT 1 FROM users u WHERE u.itop_person_id = c.itop_id)"]
|
||||||
|
params = {}
|
||||||
|
if search:
|
||||||
|
where.append("(c.name ILIKE :s OR c.email ILIKE :s)")
|
||||||
|
params["s"] = f"%{search}%"
|
||||||
|
if team:
|
||||||
|
where.append("c.team = :t")
|
||||||
|
params["t"] = team
|
||||||
|
|
||||||
|
wc = " AND ".join(where)
|
||||||
|
contacts = db.execute(text(f"""
|
||||||
|
SELECT c.id, c.itop_id, c.name, c.email, c.telephone, c.team, c.function, c.role
|
||||||
|
FROM contacts c WHERE {wc}
|
||||||
|
ORDER BY c.team, c.name LIMIT 200
|
||||||
|
"""), params).fetchall()
|
||||||
|
|
||||||
|
# Liste des teams disponibles pour filtrer
|
||||||
|
teams = db.execute(text("""
|
||||||
|
SELECT DISTINCT team FROM contacts WHERE team IS NOT NULL AND team != ''
|
||||||
|
ORDER BY team
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
ctx = base_context(request, db, user)
|
||||||
|
ctx.update({
|
||||||
|
"app_name": APP_NAME, "contacts": contacts, "teams": teams,
|
||||||
|
"roles": ROLES, "profile_labels": PROFILE_LABELS,
|
||||||
|
"profile_descriptions": PROFILE_DESCRIPTIONS,
|
||||||
|
"search": search, "team_filter": team,
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("users_add.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/add")
|
||||||
|
async def users_add(request: Request, db=Depends(get_db),
|
||||||
|
contact_id: str = Form(""), role: str = Form("viewer"),
|
||||||
|
username: str = Form(""), password: str = Form(""),
|
||||||
|
auth_type: str = Form("local"),
|
||||||
|
force_change: str = Form("")):
|
||||||
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
if role not in ROLES:
|
||||||
|
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
|
||||||
|
|
||||||
|
# Récupérer le contact iTop
|
||||||
|
contact = None
|
||||||
|
if contact_id and contact_id.strip().isdigit():
|
||||||
|
contact = db.execute(text(
|
||||||
|
"SELECT id, itop_id, name, email FROM contacts WHERE id = :cid"
|
||||||
|
), {"cid": int(contact_id)}).fetchone()
|
||||||
|
if not contact:
|
||||||
|
return RedirectResponse(url="/users/add?msg=contact_required", status_code=303)
|
||||||
|
|
||||||
|
# Générer username si non fourni (email avant @)
|
||||||
|
if not username.strip():
|
||||||
|
username = contact.email.split("@")[0] if contact.email else contact.name.lower().replace(" ", ".")
|
||||||
|
|
||||||
|
# Verifier duplicats
|
||||||
|
existing = db.execute(text("SELECT id FROM users WHERE LOWER(username)=LOWER(:u) OR itop_person_id=:iid"),
|
||||||
|
{"u": username.strip(), "iid": contact.itop_id}).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
if not existing.is_active:
|
return RedirectResponse(url="/users?msg=exists", status_code=303)
|
||||||
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(password) if password else hash_password("ChangeMe!" + str(contact.itop_id))
|
||||||
|
fpc = True if force_change == "on" else (not password)
|
||||||
|
|
||||||
pw_hash = hash_password(new_password)
|
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
INSERT INTO users (username, display_name, email, password_hash, role)
|
INSERT INTO users (username, display_name, email, password_hash, role,
|
||||||
VALUES (:u, :dn, :e, :ph, :r)
|
auth_type, itop_person_id, is_active, force_password_change)
|
||||||
"""), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None,
|
VALUES (:u, :dn, :e, :ph, :r, :at, :iid, true, :fpc)
|
||||||
"ph": pw_hash, "r": new_role})
|
"""), {
|
||||||
|
"u": username.strip(), "dn": contact.name, "e": contact.email,
|
||||||
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
|
"ph": pw_hash, "r": role, "at": auth_type,
|
||||||
if row:
|
"iid": contact.itop_id, "fpc": fpc,
|
||||||
default_perms = {
|
})
|
||||||
"admin": {m: "admin" for m in MODULES},
|
|
||||||
"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(
|
|
||||||
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l) ON CONFLICT DO NOTHING"
|
|
||||||
), {"uid": row.id, "m": mod, "l": lvl})
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/users?msg=added", status_code=303)
|
return RedirectResponse(url="/users?msg=added", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/permissions")
|
@router.post("/users/{user_id}/role")
|
||||||
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
|
async def user_change_role(request: Request, user_id: int, db=Depends(get_db),
|
||||||
user, perms, redirect = _check_access(request, db)
|
role: str = Form(...)):
|
||||||
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
if redirect:
|
if redirect:
|
||||||
return redirect
|
return redirect
|
||||||
if not can_edit(perms, "users"):
|
if role not in ROLES:
|
||||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
|
||||||
|
# Empêche un admin de se rétrograder lui-même
|
||||||
form = await request.form()
|
if user_id == user.get("uid") and role != "admin":
|
||||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
return RedirectResponse(url="/users?msg=cant_demote_self", status_code=303)
|
||||||
for mod in MODULES:
|
db.execute(text("UPDATE users SET role=:r, updated_at=NOW() WHERE id=:id"),
|
||||||
lvl = form.get(f"perm_{mod}", "")
|
{"r": role, "id": user_id})
|
||||||
if lvl and lvl in LEVELS:
|
|
||||||
db.execute(text(
|
|
||||||
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)"
|
|
||||||
), {"uid": user_id, "m": mod, "l": lvl})
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url=f"/users?msg=perms_saved", status_code=303)
|
return RedirectResponse(url="/users?msg=role_changed", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@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")
|
@router.post("/users/{user_id}/toggle")
|
||||||
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
|
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
|
||||||
user, perms, redirect = _check_access(request, db)
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
if redirect:
|
if redirect:
|
||||||
return 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"):
|
if user_id == user.get("uid"):
|
||||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
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.execute(text("UPDATE users SET is_active = NOT is_active WHERE id=:id"), {"id": user_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/users?msg=toggled", status_code=303)
|
return RedirectResponse(url="/users?msg=toggled", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/password")
|
@router.post("/users/{user_id}/password")
|
||||||
async def user_password(request: Request, user_id: int, db=Depends(get_db),
|
async def user_password(request: Request, user_id: int, db=Depends(get_db),
|
||||||
new_password: str = Form(...)):
|
new_password: str = Form(...),
|
||||||
user, perms, redirect = _check_access(request, db)
|
force_change: str = Form("")):
|
||||||
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
if redirect:
|
if redirect:
|
||||||
return redirect
|
return redirect
|
||||||
if not can_edit(perms, "users"):
|
|
||||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
|
||||||
pw_hash = hash_password(new_password)
|
pw_hash = hash_password(new_password)
|
||||||
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
|
fpc = (force_change == "on")
|
||||||
{"ph": pw_hash, "id": user_id})
|
db.execute(text("""UPDATE users SET password_hash=:ph, force_password_change=:fpc,
|
||||||
|
updated_at=NOW() WHERE id=:id"""),
|
||||||
|
{"ph": pw_hash, "fpc": fpc, "id": user_id})
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(url="/users?msg=password_changed", status_code=303)
|
return RedirectResponse(url="/users?msg=password_changed", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/auth_type")
|
||||||
|
async def user_auth_type(request: Request, user_id: int, db=Depends(get_db),
|
||||||
|
auth_type: str = Form(...)):
|
||||||
|
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||||
|
if redirect:
|
||||||
|
return redirect
|
||||||
|
if auth_type not in ("local", "ldap"):
|
||||||
|
return RedirectResponse(url="/users?msg=invalid", status_code=303)
|
||||||
|
db.execute(text("UPDATE users SET auth_type=:at, updated_at=NOW() WHERE id=:id"),
|
||||||
|
{"at": auth_type, "id": user_id})
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(url="/users?msg=auth_changed", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/delete")
|
@router.post("/users/{user_id}/delete")
|
||||||
async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
|
async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
|
||||||
user, perms, redirect = _check_access(request, db)
|
user, perms, redirect = _check_access(request, db)
|
||||||
@ -184,7 +277,11 @@ async def user_delete(request: Request, user_id: int, db=Depends(get_db)):
|
|||||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||||
if user_id == user.get("uid"):
|
if user_id == user.get("uid"):
|
||||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
||||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
# Protéger l'admin local (id=1)
|
||||||
db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id})
|
row = db.execute(text("SELECT username, auth_type FROM users WHERE id=:id"), {"id": user_id}).fetchone()
|
||||||
|
if row and row.username == "admin" and row.auth_type == "local":
|
||||||
|
return RedirectResponse(url="/users?msg=cant_delete_admin", 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()
|
db.commit()
|
||||||
return RedirectResponse(url="/users?msg=deleted", status_code=303)
|
return RedirectResponse(url="/users?msg=deleted", status_code=303)
|
||||||
|
|||||||
@ -43,19 +43,67 @@ class ITopClient:
|
|||||||
return self._call("core/create", **{"class": cls, "fields": fields, "comment": "PatchCenter sync"})
|
return self._call("core/create", **{"class": cls, "fields": fields, "comment": "PatchCenter sync"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_os_for_itop(os_string):
|
||||||
|
"""Normalise PRETTY_NAME vers un nom propre pour iTop OSVersion.
|
||||||
|
Ex: 'Debian GNU/Linux 12 (bookworm)' → 'Debian 12 (Bookworm)'
|
||||||
|
'CentOS Stream 9' → 'CentOS Stream 9'
|
||||||
|
'Red Hat Enterprise Linux 9.4 (Plow)' → 'RHEL 9.4 (Plow)'
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
s = os_string.strip()
|
||||||
|
# Debian GNU/Linux X (codename) → Debian X (Codename)
|
||||||
|
m = re.match(r'Debian GNU/Linux (\d+)\s*\((\w+)\)', s, re.I)
|
||||||
|
if m:
|
||||||
|
return f"Debian {m.group(1)} ({m.group(2).capitalize()})"
|
||||||
|
# Ubuntu X.Y LTS
|
||||||
|
m = re.match(r'Ubuntu (\d+\.\d+(?:\.\d+)?)\s*(LTS)?', s, re.I)
|
||||||
|
if m:
|
||||||
|
lts = " LTS" if m.group(2) else ""
|
||||||
|
return f"Ubuntu {m.group(1)}{lts}"
|
||||||
|
# Red Hat Enterprise Linux [Server] [release] X → RHEL X
|
||||||
|
m = re.match(r'Red Hat Enterprise Linux\s*(?:Server)?\s*(?:release)?\s*(\d+[\.\d]*)\s*\(?([\w]*)\)?', s, re.I)
|
||||||
|
if m:
|
||||||
|
codename = f" ({m.group(2).capitalize()})" if m.group(2) else ""
|
||||||
|
return f"RHEL {m.group(1)}{codename}"
|
||||||
|
# CentOS Stream release X → CentOS Stream X
|
||||||
|
m = re.match(r'CentOS Stream\s+release\s+(\d+[\.\d]*)', s, re.I)
|
||||||
|
if m:
|
||||||
|
return f"CentOS Stream {m.group(1)}"
|
||||||
|
# CentOS Linux X.Y → CentOS X.Y
|
||||||
|
m = re.match(r'CentOS\s+Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||||
|
if m:
|
||||||
|
return f"CentOS {m.group(1)}"
|
||||||
|
# Rocky Linux X.Y (codename) → Rocky Linux X.Y
|
||||||
|
m = re.match(r'Rocky\s+Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||||
|
if m:
|
||||||
|
return f"Rocky Linux {m.group(1)}"
|
||||||
|
# Oracle Linux / Oracle Enterprise Linux
|
||||||
|
m = re.match(r'Oracle\s+(?:Enterprise\s+)?Linux\s+(\d+[\.\d]*)', s, re.I)
|
||||||
|
if m:
|
||||||
|
return f"Oracle Linux {m.group(1)}"
|
||||||
|
# Fallback: remove "release" word
|
||||||
|
return re.sub(r'\s+release\s+', ' ', s).strip()
|
||||||
|
|
||||||
|
|
||||||
def _upsert_ip(db, server_id, ip):
|
def _upsert_ip(db, server_id, ip):
|
||||||
if not ip:
|
if not ip:
|
||||||
return
|
return
|
||||||
existing = db.execute(text(
|
# Remove all old itop-managed primary IPs for this server (keep only the current one)
|
||||||
|
db.execute(text(
|
||||||
|
"DELETE FROM server_ips WHERE server_id=:sid AND ip_type='primary' AND description='itop' AND ip_address != :ip"),
|
||||||
|
{"sid": server_id, "ip": ip})
|
||||||
|
# Check if this exact IP already exists
|
||||||
|
exact = db.execute(text(
|
||||||
"SELECT id FROM server_ips WHERE server_id=:sid AND ip_address=:ip"),
|
"SELECT id FROM server_ips WHERE server_id=:sid AND ip_address=:ip"),
|
||||||
{"sid": server_id, "ip": ip}).fetchone()
|
{"sid": server_id, "ip": ip}).fetchone()
|
||||||
if not existing:
|
if exact:
|
||||||
try:
|
return
|
||||||
db.execute(text(
|
try:
|
||||||
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
db.execute(text(
|
||||||
{"sid": server_id, "ip": ip})
|
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
||||||
except Exception:
|
{"sid": server_id, "ip": ip})
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _save_sync_timestamp(db, direction, stats):
|
def _save_sync_timestamp(db, direction, stats):
|
||||||
@ -154,11 +202,20 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
# ─── 5. Contacts + Teams ───
|
# ─── 5. Contacts + Teams (filtre périmètre IT uniquement) ───
|
||||||
persons = client.get_all("Person", "name,first_name,email,phone,org_name")
|
persons = client.get_all("Person", "name,first_name,email,phone,org_name,function,status")
|
||||||
|
|
||||||
|
# Périmètre IT : teams à synchroniser (configurable via settings)
|
||||||
|
from .secrets_service import get_secret as _gs
|
||||||
|
it_teams_raw = _gs(db, "itop_contact_teams")
|
||||||
|
it_teams_list = ["SecOps", "iPOP", "Externe", "DSI", "Admin DSI"]
|
||||||
|
if it_teams_raw:
|
||||||
|
it_teams_list = [t.strip() for t in it_teams_raw.split(",") if t.strip()]
|
||||||
|
|
||||||
|
# Map person → (itop_id, team)
|
||||||
|
person_info = {} # fullname_lower -> {itop_id, team}
|
||||||
|
team_members = {} # pour responsables domaine×env plus bas
|
||||||
|
|
||||||
# Get team memberships to determine role
|
|
||||||
team_members = {} # person_fullname_lower -> team_name
|
|
||||||
teams = client.get_all("Team", "name,persons_list")
|
teams = client.get_all("Team", "name,persons_list")
|
||||||
for t in teams:
|
for t in teams:
|
||||||
team_name = t.get("name", "")
|
team_name = t.get("name", "")
|
||||||
@ -167,29 +224,67 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
if pname:
|
if pname:
|
||||||
team_members[pname] = team_name
|
team_members[pname] = team_name
|
||||||
|
|
||||||
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif", "Externe": "referent_technique"}
|
team_role_map = {"SecOps": "referent_technique", "iPOP": "responsable_applicatif",
|
||||||
|
"Externe": "referent_technique", "DSI": "referent_technique",
|
||||||
|
"Admin DSI": "referent_technique"}
|
||||||
|
|
||||||
|
# Set des itop_id et emails vus dans le périmètre IT (pour désactiver les autres)
|
||||||
|
seen_itop_ids = set()
|
||||||
|
seen_emails = set()
|
||||||
|
stats["contacts_deactivated"] = 0
|
||||||
|
|
||||||
for p in persons:
|
for p in persons:
|
||||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||||
email = p.get("email", "")
|
email = p.get("email", "")
|
||||||
if not email:
|
if not email:
|
||||||
continue
|
continue
|
||||||
# Determine role from team
|
|
||||||
team = team_members.get(fullname.lower(), "")
|
team = team_members.get(fullname.lower(), "")
|
||||||
|
# Filtre : ne synchroniser que les persons dans le périmètre IT
|
||||||
|
if team not in it_teams_list:
|
||||||
|
continue
|
||||||
role = team_role_map.get(team, "referent_technique")
|
role = team_role_map.get(team, "referent_technique")
|
||||||
|
itop_id = p.get("itop_id")
|
||||||
|
phone = p.get("phone", "")
|
||||||
|
function = p.get("function", "")
|
||||||
|
# Status iTop : active/inactive
|
||||||
|
itop_status = (p.get("status", "") or "").lower()
|
||||||
|
is_active = itop_status != "inactive"
|
||||||
|
|
||||||
|
if itop_id:
|
||||||
|
seen_itop_ids.add(int(itop_id))
|
||||||
|
seen_emails.add(email.lower())
|
||||||
|
|
||||||
existing = db.execute(text("SELECT id FROM contacts WHERE LOWER(email)=LOWER(:e)"), {"e": email}).fetchone()
|
existing = db.execute(text("SELECT id FROM contacts WHERE LOWER(email)=LOWER(:e)"), {"e": email}).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
db.execute(text("UPDATE contacts SET name=:n, role=:r, updated_at=NOW() WHERE id=:id"),
|
db.execute(text("""UPDATE contacts SET name=:n, role=:r, itop_id=:iid,
|
||||||
{"id": existing.id, "n": fullname, "r": role})
|
telephone=:tel, team=:t, function=:f, is_active=:a, updated_at=NOW() WHERE id=:id"""),
|
||||||
|
{"id": existing.id, "n": fullname, "r": role, "iid": itop_id,
|
||||||
|
"tel": phone, "t": team, "f": function, "a": is_active})
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
db.execute(text("INSERT INTO contacts (name, email, role) VALUES (:n, :e, :r)"),
|
db.execute(text("""INSERT INTO contacts (name, email, role, itop_id,
|
||||||
{"n": fullname, "e": email, "r": role})
|
telephone, team, function, is_active) VALUES (:n, :e, :r, :iid, :tel, :t, :f, :a)"""),
|
||||||
|
{"n": fullname, "e": email, "r": role, "iid": itop_id,
|
||||||
|
"tel": phone, "t": team, "f": function, "a": is_active})
|
||||||
stats["contacts"] += 1
|
stats["contacts"] += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
|
# Désactiver les contacts iTop qui ne sont plus dans le périmètre (plus dans les teams IT)
|
||||||
|
# Critère : a un itop_id mais n'a pas été vu dans le sync
|
||||||
|
if seen_itop_ids:
|
||||||
|
placeholders = ",".join(str(i) for i in seen_itop_ids)
|
||||||
|
r = db.execute(text(f"""UPDATE contacts SET is_active=false, updated_at=NOW()
|
||||||
|
WHERE itop_id IS NOT NULL AND itop_id NOT IN ({placeholders}) AND is_active=true"""))
|
||||||
|
stats["contacts_deactivated"] = r.rowcount
|
||||||
|
|
||||||
|
# Désactiver les users PatchCenter liés à des contacts devenus inactifs
|
||||||
|
stats["users_deactivated"] = 0
|
||||||
|
r = db.execute(text("""UPDATE users SET is_active=false, updated_at=NOW()
|
||||||
|
WHERE is_active=true AND itop_person_id IN
|
||||||
|
(SELECT itop_id FROM contacts WHERE is_active=false AND itop_id IS NOT NULL)"""))
|
||||||
|
stats["users_deactivated"] = r.rowcount
|
||||||
|
|
||||||
# ─── 6. Build lookup maps ───
|
# ─── 6. Build lookup maps ───
|
||||||
domain_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM domains")).fetchall()}
|
domain_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM domains")).fetchall()}
|
||||||
env_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM environments")).fetchall()}
|
env_map = {r.name.lower(): r.id for r in db.execute(text("SELECT id, name FROM environments")).fetchall()}
|
||||||
@ -206,6 +301,30 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
de_responsables = defaultdict(lambda: {"resp_dom": defaultdict(int), "resp_dom_email": {},
|
de_responsables = defaultdict(lambda: {"resp_dom": defaultdict(int), "resp_dom_email": {},
|
||||||
"referent": defaultdict(int), "referent_email": {}})
|
"referent": defaultdict(int), "referent_email": {}})
|
||||||
|
|
||||||
|
# ─── 7bis. ApplicationSolutions ───
|
||||||
|
crit_map = {"high": "haute", "critical": "critique", "medium": "standard", "low": "basse"}
|
||||||
|
itop_apps = client.get_all("ApplicationSolution", "name,description,business_criticity,status")
|
||||||
|
for app in itop_apps:
|
||||||
|
iid = app.get("itop_id")
|
||||||
|
name = (app.get("name") or "")[:50]
|
||||||
|
full = (app.get("name") or "")[:200]
|
||||||
|
desc = (app.get("description") or "")[:500]
|
||||||
|
crit = crit_map.get((app.get("business_criticity") or "").lower(), "basse")
|
||||||
|
st = (app.get("status") or "active")[:30]
|
||||||
|
try:
|
||||||
|
db.execute(text("""INSERT INTO applications (itop_id, nom_court, nom_complet, description, criticite, status)
|
||||||
|
VALUES (:iid, :n, :nc, :d, :c, :s)
|
||||||
|
ON CONFLICT (itop_id) DO UPDATE SET nom_court=EXCLUDED.nom_court,
|
||||||
|
nom_complet=EXCLUDED.nom_complet, description=EXCLUDED.description,
|
||||||
|
criticite=EXCLUDED.criticite, status=EXCLUDED.status, updated_at=NOW()"""),
|
||||||
|
{"iid": iid, "n": name, "nc": full, "d": desc, "c": crit, "s": st})
|
||||||
|
stats["applications"] = stats.get("applications", 0) + 1
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
db.commit()
|
||||||
|
app_by_itop_id = {r.itop_id: r.id for r in db.execute(text(
|
||||||
|
"SELECT id, itop_id FROM applications WHERE itop_id IS NOT NULL")).fetchall()}
|
||||||
|
|
||||||
# ─── 8. VirtualMachines ───
|
# ─── 8. VirtualMachines ───
|
||||||
vms = client.get_all("VirtualMachine",
|
vms = client.get_all("VirtualMachine",
|
||||||
"name,description,status,managementip,osfamily_id_friendlyname,"
|
"name,description,status,managementip,osfamily_id_friendlyname,"
|
||||||
@ -215,10 +334,12 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
"contacts_list,virtualhost_name,business_criticity,"
|
"contacts_list,virtualhost_name,business_criticity,"
|
||||||
"tier_name,connexion_method_name,ssh_user_name,"
|
"tier_name,connexion_method_name,ssh_user_name,"
|
||||||
"patch_frequency_name,pref_patch_jour_name,patch_window,"
|
"patch_frequency_name,pref_patch_jour_name,patch_window,"
|
||||||
"patch_excludes,domain_ldap_name,last_patch_date")
|
"patch_excludes,domain_ldap_name,last_patch_date,"
|
||||||
|
"applicationsolution_list")
|
||||||
|
|
||||||
itop_status = {"production": "en_production", "stock": "stock",
|
# PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete)
|
||||||
"implementation": "en_cours", "obsolete": "decommissionne"}
|
itop_status = {"production": "production", "stock": "stock",
|
||||||
|
"implementation": "implementation", "obsolete": "obsolete"}
|
||||||
|
|
||||||
for v in vms:
|
for v in vms:
|
||||||
hostname = v.get("name", "").split(".")[0].lower()
|
hostname = v.get("name", "").split(".")[0].lower()
|
||||||
@ -264,12 +385,25 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
resp_srv_name = v.get("responsable_serveur_name", "")
|
resp_srv_name = v.get("responsable_serveur_name", "")
|
||||||
resp_dom_name = v.get("responsable_domaine_name", "")
|
resp_dom_name = v.get("responsable_domaine_name", "")
|
||||||
|
|
||||||
|
# ApplicationSolution (première app si plusieurs)
|
||||||
|
app_id = None
|
||||||
|
app_name = None
|
||||||
|
apps_list = v.get("applicationsolution_list") or []
|
||||||
|
if apps_list:
|
||||||
|
first = apps_list[0]
|
||||||
|
try:
|
||||||
|
itop_aid = int(first.get("applicationsolution_id", 0))
|
||||||
|
app_id = app_by_itop_id.get(itop_aid)
|
||||||
|
app_name = first.get("applicationsolution_name", "")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
"hostname": hostname, "fqdn": v.get("name", hostname),
|
"hostname": hostname, "fqdn": v.get("name", hostname),
|
||||||
"os_family": "linux" if "linux" in v.get("osfamily_id_friendlyname", "").lower() else "windows",
|
"os_family": "linux" if "linux" in v.get("osfamily_id_friendlyname", "").lower() else "windows",
|
||||||
"os_version": v.get("osversion_id_friendlyname", ""),
|
"os_version": v.get("osversion_id_friendlyname", ""),
|
||||||
"machine_type": "vm",
|
"machine_type": "vm",
|
||||||
"etat": itop_status.get(v.get("status", ""), "en_production"),
|
"etat": itop_status.get(v.get("status", ""), "production"),
|
||||||
"de_id": de_id, "zone_id": zone_id,
|
"de_id": de_id, "zone_id": zone_id,
|
||||||
"resp_srv": resp_srv_name,
|
"resp_srv": resp_srv_name,
|
||||||
"resp_srv_email": person_email.get(resp_srv_name.lower(), ""),
|
"resp_srv_email": person_email.get(resp_srv_name.lower(), ""),
|
||||||
@ -282,6 +416,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
"patch_freq": patch_freq, "patch_excludes": v.get("patch_excludes", ""),
|
"patch_freq": patch_freq, "patch_excludes": v.get("patch_excludes", ""),
|
||||||
"domain_ltd": v.get("domain_ldap_name", ""),
|
"domain_ltd": v.get("domain_ldap_name", ""),
|
||||||
"pref_jour": pref_jour, "pref_heure": pref_heure,
|
"pref_jour": pref_jour, "pref_heure": pref_heure,
|
||||||
|
"app_id": app_id, "app_name": app_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
||||||
@ -293,6 +428,7 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
tier=:tier, ssh_method=:ssh_method, ssh_user=:ssh_user,
|
tier=:tier, ssh_method=:ssh_method, ssh_user=:ssh_user,
|
||||||
patch_frequency=:patch_freq, patch_excludes=:patch_excludes,
|
patch_frequency=:patch_freq, patch_excludes=:patch_excludes,
|
||||||
domain_ltd=:domain_ltd, pref_patch_jour=:pref_jour, pref_patch_heure=:pref_heure,
|
domain_ltd=:domain_ltd, pref_patch_jour=:pref_jour, pref_patch_heure=:pref_heure,
|
||||||
|
application_id=:app_id, application_name=:app_name,
|
||||||
updated_at=NOW() WHERE id=:sid"""), {**vals, "sid": existing.id})
|
updated_at=NOW() WHERE id=:sid"""), {**vals, "sid": existing.id})
|
||||||
if vals["ip"]:
|
if vals["ip"]:
|
||||||
_upsert_ip(db, existing.id, vals["ip"])
|
_upsert_ip(db, existing.id, vals["ip"])
|
||||||
@ -341,22 +477,33 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
hostname = s.get("name", "").split(".")[0].lower()
|
hostname = s.get("name", "").split(".")[0].lower()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
continue
|
continue
|
||||||
|
contacts = s.get("contacts_list", [])
|
||||||
|
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
|
||||||
|
ip = s.get("managementip", "")
|
||||||
|
osf = "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows"
|
||||||
|
osv = s.get("osversion_id_friendlyname", "")
|
||||||
|
|
||||||
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
existing = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"), {"h": hostname}).fetchone()
|
||||||
if not existing:
|
if existing:
|
||||||
|
db.execute(text("""UPDATE servers SET fqdn=:f, os_family=:osf, os_version=:osv,
|
||||||
|
responsable_nom=:resp, commentaire=:desc, site=:site, updated_at=NOW()
|
||||||
|
WHERE id=:sid"""),
|
||||||
|
{"f": s.get("name", hostname), "osf": osf, "osv": osv,
|
||||||
|
"resp": resp, "desc": s.get("description", ""),
|
||||||
|
"site": s.get("location_name", ""), "sid": existing.id})
|
||||||
|
if ip:
|
||||||
|
_upsert_ip(db, existing.id, ip)
|
||||||
|
stats["servers_updated"] += 1
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
contacts = s.get("contacts_list", [])
|
|
||||||
resp = contacts[0].get("contact_id_friendlyname", "") if contacts else ""
|
|
||||||
db.execute(text("""INSERT INTO servers (hostname, fqdn, os_family, os_version, machine_type,
|
db.execute(text("""INSERT INTO servers (hostname, fqdn, os_family, os_version, machine_type,
|
||||||
etat, responsable_nom, commentaire, site, ssh_port, ssh_user, ssh_method, tier)
|
etat, responsable_nom, commentaire, site, ssh_port, ssh_user, ssh_method, tier)
|
||||||
VALUES (:h, :f, :osf, :osv, 'physical', 'en_production', :resp, :desc, :site,
|
VALUES (:h, :f, :osf, :osv, 'physical', 'production', :resp, :desc, :site,
|
||||||
22, 'root', 'ssh_key', 'tier0')"""),
|
22, 'root', 'ssh_key', 'tier0')"""),
|
||||||
{"h": hostname, "f": s.get("name", hostname),
|
{"h": hostname, "f": s.get("name", hostname), "osf": osf, "osv": osv,
|
||||||
"osf": "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows",
|
|
||||||
"osv": s.get("osversion_id_friendlyname", ""),
|
|
||||||
"resp": resp, "desc": s.get("description", ""),
|
"resp": resp, "desc": s.get("description", ""),
|
||||||
"site": s.get("location_name", "")})
|
"site": s.get("location_name", "")})
|
||||||
db.flush()
|
db.flush()
|
||||||
ip = s.get("managementip", "")
|
|
||||||
if ip:
|
if ip:
|
||||||
new_srv = db.execute(text("SELECT id FROM servers WHERE hostname=:h"), {"h": hostname}).fetchone()
|
new_srv = db.execute(text("SELECT id FROM servers WHERE hostname=:h"), {"h": hostname}).fetchone()
|
||||||
if new_srv:
|
if new_srv:
|
||||||
@ -414,26 +561,42 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
for v in client.get_all("VirtualMachine", "name"):
|
for v in client.get_all("VirtualMachine", "name"):
|
||||||
itop_vms[v["name"].split(".")[0].lower()] = v
|
itop_vms[v["name"].split(".")[0].lower()] = v
|
||||||
|
|
||||||
status_map = {"en_production": "production", "decommissionne": "obsolete",
|
status_map = {"production": "production", "implementation": "implementation",
|
||||||
"stock": "stock", "en_cours": "implementation"}
|
"stock": "stock", "obsolete": "obsolete"}
|
||||||
tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"}
|
tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"}
|
||||||
|
|
||||||
|
# Build OSVersion cache: name.lower() → itop_id
|
||||||
|
itop_osversions = {}
|
||||||
|
for ov in client.get_all("OSVersion", "name,osfamily_name"):
|
||||||
|
itop_osversions[ov["name"].lower()] = ov
|
||||||
|
|
||||||
|
# Build OSFamily cache
|
||||||
|
itop_osfamilies = {}
|
||||||
|
for of in client.get_all("OSFamily", "name"):
|
||||||
|
itop_osfamilies[of["name"].lower()] = of["itop_id"]
|
||||||
|
|
||||||
# Build Person name → itop_id lookup for responsable sync
|
# Build Person name → itop_id lookup for responsable sync
|
||||||
itop_persons = {}
|
itop_persons = {}
|
||||||
for p in client.get_all("Person", "name,first_name"):
|
for p in client.get_all("Person", "name,first_name"):
|
||||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||||
itop_persons[fullname.lower()] = p["itop_id"]
|
itop_persons[fullname.lower()] = p["itop_id"]
|
||||||
|
|
||||||
rows = db.execute(text("""SELECT hostname, fqdn, os_version, etat, commentaire, tier,
|
rows = db.execute(text("""SELECT s.hostname, s.fqdn, s.os_version, s.os_family, s.etat, s.commentaire, s.tier,
|
||||||
ssh_method, ssh_user, patch_frequency, patch_excludes, domain_ltd,
|
s.ssh_method, s.ssh_user, s.patch_frequency, s.patch_excludes, s.domain_ltd,
|
||||||
pref_patch_jour, pref_patch_heure, responsable_nom, referent_nom
|
s.pref_patch_jour, s.pref_patch_heure, s.responsable_nom, s.referent_nom,
|
||||||
FROM servers WHERE machine_type='vm'""")).fetchall()
|
s.application_id,
|
||||||
|
(SELECT si.ip_address::text FROM server_ips si WHERE si.server_id = s.id AND si.ip_type = 'primary' LIMIT 1) as mgmt_ip,
|
||||||
|
(SELECT sa.audit_date FROM server_audit sa WHERE sa.server_id = s.id ORDER BY sa.audit_date DESC LIMIT 1) as last_audit_date,
|
||||||
|
(SELECT a.itop_id FROM applications a WHERE a.id = s.application_id) as app_itop_id
|
||||||
|
FROM servers s WHERE s.machine_type='vm'""")).fetchall()
|
||||||
|
|
||||||
for srv in rows:
|
for srv in rows:
|
||||||
hostname = (srv.hostname or "").lower()
|
hostname = (srv.hostname or "").lower()
|
||||||
itop_vm = itop_vms.get(hostname)
|
itop_vm = itop_vms.get(hostname)
|
||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
|
if srv.mgmt_ip:
|
||||||
|
fields["managementip"] = srv.mgmt_ip.split("/")[0]
|
||||||
if srv.etat:
|
if srv.etat:
|
||||||
fields["status"] = status_map.get(srv.etat, "production")
|
fields["status"] = status_map.get(srv.etat, "production")
|
||||||
if srv.commentaire:
|
if srv.commentaire:
|
||||||
@ -455,6 +618,29 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass):
|
|||||||
fields["patch_window"] = srv.pref_patch_heure
|
fields["patch_window"] = srv.pref_patch_heure
|
||||||
if srv.domain_ltd:
|
if srv.domain_ltd:
|
||||||
fields["domain_ldap_id"] = f"SELECT DomainLdap WHERE name = '{srv.domain_ltd}'"
|
fields["domain_ldap_id"] = f"SELECT DomainLdap WHERE name = '{srv.domain_ltd}'"
|
||||||
|
# Date dernier audit
|
||||||
|
if srv.last_audit_date:
|
||||||
|
fields["last_patch_date"] = str(srv.last_audit_date)[:10]
|
||||||
|
# ApplicationSolution : pousser si défini (replace la liste)
|
||||||
|
if srv.app_itop_id:
|
||||||
|
fields["applicationsolution_list"] = [{"applicationsolution_id": int(srv.app_itop_id)}]
|
||||||
|
# OS version — chercher/créer dans iTop
|
||||||
|
if srv.os_version:
|
||||||
|
osv_name = _normalize_os_for_itop(srv.os_version)
|
||||||
|
osf_name = "Linux" if srv.os_family == "linux" else "Windows" if srv.os_family == "windows" else "Linux"
|
||||||
|
match = itop_osversions.get(osv_name.lower())
|
||||||
|
if match:
|
||||||
|
fields["osversion_id"] = match["itop_id"]
|
||||||
|
else:
|
||||||
|
# Créer l'OSVersion dans iTop
|
||||||
|
osf_id = itop_osfamilies.get(osf_name.lower())
|
||||||
|
if osf_id:
|
||||||
|
cr = client.create("OSVersion", {"name": osv_name, "osfamily_id": osf_id})
|
||||||
|
if cr.get("code") == 0 and cr.get("objects"):
|
||||||
|
new_id = list(cr["objects"].values())[0]["key"]
|
||||||
|
fields["osversion_id"] = new_id
|
||||||
|
itop_osversions[osv_name.lower()] = {"itop_id": new_id, "name": osv_name}
|
||||||
|
stats["ref_created"] += 1
|
||||||
# Responsable serveur
|
# Responsable serveur
|
||||||
if srv.responsable_nom:
|
if srv.responsable_nom:
|
||||||
pid = itop_persons.get(srv.responsable_nom.lower())
|
pid = itop_persons.get(srv.responsable_nom.lower())
|
||||||
|
|||||||
113
app/services/ldap_service.py
Normal file
113
app/services/ldap_service.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""Service LDAP/AD — authentification via annuaire.
|
||||||
|
|
||||||
|
Configuration via settings (clés) :
|
||||||
|
- ldap_enabled : "true" / "false"
|
||||||
|
- ldap_server : URL du serveur (ex: ldaps://ad.sanef.com:636)
|
||||||
|
- ldap_base_dn : DN de base (ex: DC=sanef,DC=com)
|
||||||
|
- ldap_bind_dn : DN du compte de bind (ex: CN=svc_pc,OU=SVC,DC=sanef,DC=com)
|
||||||
|
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
|
||||||
|
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
|
||||||
|
- ldap_tls : "true" / "false"
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
|
||||||
|
import ssl
|
||||||
|
LDAP_OK = True
|
||||||
|
except ImportError:
|
||||||
|
LDAP_OK = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config(db):
|
||||||
|
"""Retourne la config LDAP depuis app_secrets (via get_secret)."""
|
||||||
|
from .secrets_service import get_secret
|
||||||
|
|
||||||
|
def s(k, default=""):
|
||||||
|
return get_secret(db, k) or default
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": s("ldap_enabled", "false").lower() == "true",
|
||||||
|
"server": s("ldap_server"),
|
||||||
|
"base_dn": s("ldap_base_dn"),
|
||||||
|
"bind_dn": s("ldap_bind_dn"),
|
||||||
|
"bind_pwd": s("ldap_bind_pwd"),
|
||||||
|
"user_filter": s("ldap_user_filter", "(sAMAccountName={username})"),
|
||||||
|
"tls": s("ldap_tls", "true").lower() == "true",
|
||||||
|
"email_attr": s("ldap_email_attr", "mail"),
|
||||||
|
"name_attr": s("ldap_name_attr", "displayName"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled(db):
|
||||||
|
"""LDAP activé et configuré ?"""
|
||||||
|
cfg = _get_config(db)
|
||||||
|
return cfg["enabled"] and cfg["server"] and cfg["base_dn"]
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(db, username, password):
|
||||||
|
"""Tente l'authentification LDAP.
|
||||||
|
Retourne dict {ok, email, name, dn} ou {ok: False, msg: '...'}"""
|
||||||
|
if not LDAP_OK:
|
||||||
|
return {"ok": False, "msg": "Module ldap3 non installé"}
|
||||||
|
|
||||||
|
cfg = _get_config(db)
|
||||||
|
if not cfg["enabled"]:
|
||||||
|
return {"ok": False, "msg": "LDAP désactivé"}
|
||||||
|
if not cfg["server"] or not cfg["base_dn"]:
|
||||||
|
return {"ok": False, "msg": "LDAP non configuré"}
|
||||||
|
|
||||||
|
# 1. Bind service account pour chercher le DN de l'utilisateur
|
||||||
|
try:
|
||||||
|
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
||||||
|
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"LDAP bind failed: {e}")
|
||||||
|
return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
|
||||||
|
|
||||||
|
# 2. Recherche de l'utilisateur
|
||||||
|
user_filter = cfg["user_filter"].replace("{username}", username)
|
||||||
|
try:
|
||||||
|
conn.search(cfg["base_dn"], user_filter,
|
||||||
|
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName"])
|
||||||
|
except Exception as e:
|
||||||
|
conn.unbind()
|
||||||
|
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
|
||||||
|
|
||||||
|
if not conn.entries:
|
||||||
|
conn.unbind()
|
||||||
|
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"}
|
||||||
|
|
||||||
|
entry = conn.entries[0]
|
||||||
|
user_dn = str(entry.distinguishedName) if hasattr(entry, "distinguishedName") else entry.entry_dn
|
||||||
|
email = str(getattr(entry, cfg["email_attr"], "")) or ""
|
||||||
|
name = str(getattr(entry, cfg["name_attr"], "")) or username
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
# 3. Bind avec les credentials fournis
|
||||||
|
try:
|
||||||
|
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||||
|
user_conn.unbind()
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": "Mot de passe incorrect"}
|
||||||
|
|
||||||
|
return {"ok": True, "dn": user_dn, "email": email, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection(db):
|
||||||
|
"""Test la connexion LDAP avec le compte de bind (pour admin)."""
|
||||||
|
if not LDAP_OK:
|
||||||
|
return {"ok": False, "msg": "Module ldap3 non installé"}
|
||||||
|
cfg = _get_config(db)
|
||||||
|
if not cfg["server"]:
|
||||||
|
return {"ok": False, "msg": "Serveur non configuré"}
|
||||||
|
try:
|
||||||
|
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
||||||
|
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||||
|
conn.unbind()
|
||||||
|
return {"ok": True, "msg": "Connexion réussie"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": str(e)[:200]}
|
||||||
58
app/services/profile_service.py
Normal file
58
app/services/profile_service.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""Profils utilisateurs PatchCenter — mapping role → permissions pré-définies.
|
||||||
|
|
||||||
|
4 profils :
|
||||||
|
- admin : tout (view/edit/admin sur tous les modules)
|
||||||
|
- coordinator : SecOps + coordination (Patcheur + gestion campagnes/planning)
|
||||||
|
- operator : Patcheur (intervenant SecOps — exécution patching)
|
||||||
|
- viewer : Invité (view-only : dashboard, servers, qualys, audit)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Matrix profil → {module: level}
|
||||||
|
# level: "view" | "edit" | "admin"
|
||||||
|
PROFILES = {
|
||||||
|
"admin": {
|
||||||
|
"dashboard": "admin", "servers": "admin", "campaigns": "admin",
|
||||||
|
"planning": "admin", "specifics": "admin", "audit": "admin",
|
||||||
|
"contacts": "admin", "qualys": "admin", "quickwin": "admin",
|
||||||
|
"users": "admin", "settings": "admin", "referentiel": "admin",
|
||||||
|
},
|
||||||
|
# Coordinateur = SecOps + gestion campagnes/planning
|
||||||
|
"coordinator": {
|
||||||
|
"dashboard": "view", "servers": "edit", "campaigns": "admin",
|
||||||
|
"planning": "edit", "specifics": "edit", "audit": "edit",
|
||||||
|
"contacts": "view", "qualys": "edit", "quickwin": "admin",
|
||||||
|
"users": "view", "referentiel": "view",
|
||||||
|
},
|
||||||
|
# Patcheur = intervenant SecOps
|
||||||
|
"operator": {
|
||||||
|
"dashboard": "view", "servers": "view", "campaigns": "view",
|
||||||
|
"planning": "view", "audit": "edit", "qualys": "view",
|
||||||
|
"quickwin": "edit", "contacts": "view",
|
||||||
|
},
|
||||||
|
# Invité = view-only (pas d'accès à l'audit)
|
||||||
|
"viewer": {
|
||||||
|
"dashboard": "view", "servers": "view", "qualys": "view",
|
||||||
|
"contacts": "view", "planning": "view", "quickwin": "view",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_perms(role: str) -> dict:
|
||||||
|
"""Retourne les permissions pour un profil donné."""
|
||||||
|
return dict(PROFILES.get(role, {}))
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_LABELS = {
|
||||||
|
"admin": "Admin",
|
||||||
|
"coordinator": "Coordinateur",
|
||||||
|
"operator": "Patcheur",
|
||||||
|
"viewer": "Invité",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_DESCRIPTIONS = {
|
||||||
|
"admin": "Accès complet : gestion des utilisateurs, paramètres, tous les modules en admin",
|
||||||
|
"coordinator": "SecOps + coordination : gestion des campagnes, planning, exécution patching",
|
||||||
|
"operator": "Patcheur (intervenant SecOps) : exécution du patching, audit des serveurs",
|
||||||
|
"viewer": "Invité : consultation en lecture seule (dashboard, serveurs, Qualys, audit)",
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@
|
|||||||
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
|
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
|
||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
|
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
|
||||||
@ -49,31 +50,111 @@
|
|||||||
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
|
<h1 class="text-cyber-accent font-bold text-lg mt-1">PatchCenter</h1>
|
||||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 p-3 space-y-1">
|
<nav class="flex-1 p-3 space-y-1" x-data='{
|
||||||
|
open: localStorage.getItem("menu_open") || "",
|
||||||
|
subOpen: localStorage.getItem("menu_sub_open") || "",
|
||||||
|
toggle(k){ this.open = (this.open === k) ? "" : k; this.subOpen = ""; localStorage.setItem("menu_open", this.open); localStorage.setItem("menu_sub_open", ""); },
|
||||||
|
toggleSub(k){ this.subOpen = (this.subOpen === k) ? "" : k; localStorage.setItem("menu_sub_open", this.subOpen); }
|
||||||
|
}'>
|
||||||
{% set p = perms if perms is defined else request.state.perms %}
|
{% set p = perms if perms is defined else request.state.perms %}
|
||||||
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
{% set path = request.url.path %}
|
||||||
{% if p.servers %}<a href="/servers" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/servers' or '/servers/' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Serveurs</a>{% endif %}
|
|
||||||
|
|
||||||
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path and 'assignments' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>{% endif %}
|
{# Dashboard principal #}
|
||||||
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'assignments' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Assignations</a>{% endif %}
|
{% if p.servers or p.qualys or p.audit or p.planning %}<a href="/dashboard" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'dashboard' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Dashboard</a>{% endif %}
|
||||||
{% if p.qualys %}<a href="/qualys/search" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/' in request.url.path and 'tags' not in request.url.path and 'decoder' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Qualys</a>{% endif %}
|
|
||||||
{% if p.qualys %}<a href="/qualys/tags" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/qualys/tags' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Tags</a>{% endif %}
|
{# Serveurs (groupe repliable avec Correspondance) #}
|
||||||
{% if p.qualys %}<a href="/qualys/decoder" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'decoder' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Décodeur</a>{% endif %}
|
{% if p.servers %}
|
||||||
{% if p.qualys %}<a href="/qualys/agents" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'agents' in request.url.path and 'deploy' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Agents</a>{% endif %}
|
<div>
|
||||||
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'deploy' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Déployer Agent</a>{% endif %}
|
<button @click="toggle('servers')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||||
{% if p.campaigns %}<a href="/safe-patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'safe-patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Safe Patching</a>{% endif %}
|
<span>Serveurs</span>
|
||||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'quickwin' in request.url.path and 'safe' not in request.url.path and 'config' not in request.url.path and 'correspondance' not in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</a>{% endif %}
|
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
|
||||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin/config" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if '/quickwin/config' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Config exclusions</a>{% endif %}
|
</button>
|
||||||
{% if p.campaigns or p.quickwin %}<a href="/quickwin/correspondance" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'correspondance' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Correspondance</a>{% endif %}
|
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
|
||||||
{% if p.planning %}<a href="/planning" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'planning' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Planning</a>{% endif %}
|
<a href="/servers" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/servers' or path.startswith('/servers/') %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Liste</a>
|
||||||
{% if p.audit %}<a href="/audit" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Audit</a>{% endif %}
|
{% if p.campaigns or p.quickwin %}<a href="/patching/correspondance" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Correspondance prod ↔ hors-prod</a>{% endif %}
|
||||||
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'specific' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Spécifique</a>{% endif %}
|
</div>
|
||||||
{% if p.audit %}<a href="/audit-full" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if request.url.path == '/audit-full' or (request.url.path.startswith('/audit-full/') and 'patching' not in request.url.path and 'flow-map' not in request.url.path) %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Complet</a>{% endif %}
|
</div>
|
||||||
{% if p.audit %}<a href="/audit-full/patching" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'patching' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Patching</a>{% endif %}
|
{% endif %}
|
||||||
{% if p.servers %}<a href="/contacts" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'contacts' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Contacts</a>{% endif %}
|
|
||||||
{% if p.users %}<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>{% endif %}
|
{# ===== PATCHING (groupe repliable) ===== #}
|
||||||
{% if p.settings %}<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>{% endif %}
|
{% if p.campaigns or p.planning or p.quickwin %}
|
||||||
{% if p.settings %}<a href="/referentiel" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'referentiel' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6 text-xs">Référentiel</a>{% endif %}
|
<div>
|
||||||
|
<button @click="toggle('patching')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||||
|
<span>Patching</span>
|
||||||
|
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
|
||||||
|
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
|
||||||
|
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %}
|
||||||
|
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
|
||||||
|
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}
|
||||||
|
{% if p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/validations" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/validations' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Validations</a>{% endif %}
|
||||||
|
|
||||||
|
{# Quickwin sous-groupe #}
|
||||||
|
{% if p.campaigns or p.quickwin %}
|
||||||
|
<div>
|
||||||
|
<button @click="toggleSub('quickwin')" class="w-full flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 pl-6 {% if 'quickwin' in path %}text-cyber-accent{% else %}text-gray-400{% endif %}">
|
||||||
|
<span>QuickWin</span>
|
||||||
|
<span x-text="subOpen === 'quickwin' ? '▾' : '▸'" class="text-xs opacity-60"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="subOpen === 'quickwin'" x-cloak class="space-y-1">
|
||||||
|
<a href="/quickwin" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'quickwin' in path and 'config' not in path and 'correspondance' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Vue d'ensemble</a>
|
||||||
|
{% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %}
|
||||||
|
<a href="/quickwin/config" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if '/quickwin/config' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Config exclusion</a>
|
||||||
|
<a href="/quickwin/correspondance" style="padding-left:3rem" class="block py-1 pr-3 rounded-md text-xs hover:bg-cyber-border/30 {% if 'correspondance' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-500{% endif %}">Correspondance</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== AUDIT (au meme niveau que Patching, repliable) ===== #}
|
||||||
|
{% if p.audit %}
|
||||||
|
<div>
|
||||||
|
<button @click="toggle('audit')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||||
|
<span>Audit</span>
|
||||||
|
<span x-text="open === 'audit' ? '▾' : '▸'" class="text-xs"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'audit'" x-cloak class="space-y-1 pl-1">
|
||||||
|
<a href="/audit" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/audit' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Audit global</a>
|
||||||
|
{% if p.audit in ('edit', 'admin') %}<a href="/audit/specific" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'specific' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Spécifique</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== QUALYS (groupe repliable) ===== #}
|
||||||
|
{% if p.qualys %}
|
||||||
|
<div>
|
||||||
|
<button @click="toggle('qualys')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||||
|
<span>Qualys</span>
|
||||||
|
<span x-text="open === 'qualys' ? '▾' : '▸'" class="text-xs"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'qualys'" x-cloak class="space-y-1 pl-1">
|
||||||
|
<a href="/qualys/search" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/search' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Recherche</a>
|
||||||
|
<a href="/qualys/tags" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tags' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Tags</a>
|
||||||
|
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
|
||||||
|
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== ADMIN (groupe repliable) ===== #}
|
||||||
|
{% if p.users or p.settings or p.servers or p.contacts %}
|
||||||
|
<div>
|
||||||
|
<button @click="toggle('admin')" class="w-full flex justify-between items-center px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 text-cyber-accent font-bold">
|
||||||
|
<span>Administration</span>
|
||||||
|
<span x-text="open === 'admin' ? '▾' : '▸'" class="text-xs"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'admin'" x-cloak class="space-y-1 pl-1">
|
||||||
|
{% if p.servers or p.contacts %}<a href="/contacts" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'contacts' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Contacts</a>{% endif %}
|
||||||
|
{% if p.users %}<a href="/users" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'users' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Utilisateurs</a>{% endif %}
|
||||||
|
{% if p.settings %}<a href="/settings" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'settings' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Settings</a>{% endif %}
|
||||||
|
{% if p.settings or p.referentiel %}<a href="/referentiel" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'referentiel' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Référentiel</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="flex-1 flex flex-col overflow-hidden">
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|||||||
54
app/templates/change_password.html
Normal file
54
app/templates/change_password.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ app_name }} - Changement de mot de passe</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/css/tailwind.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||||
|
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
|
||||||
|
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
|
||||||
|
.btn-primary:hover { background: #00b8e6; }
|
||||||
|
input { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
|
||||||
|
input:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="card p-8 w-96">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<img src="/static/logo_sanef.jpg" alt="SANEF" class="h-12 mx-auto mb-3 rounded" style="opacity:0.9">
|
||||||
|
<h1 class="text-xl font-bold text-cyber-accent">Changement de mot de passe requis</h1>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Bienvenue <b>{{ user.sub }}</b></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Vous devez définir un nouveau mot de passe avant de continuer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-cyber-red/20 text-cyber-red text-sm p-3 rounded mb-4">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/me/change-password" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Mot de passe actuel</label>
|
||||||
|
<input type="password" name="current_password" required autofocus class="w-full">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Nouveau mot de passe <span class="text-gray-600">(min 8 caractères)</span></label>
|
||||||
|
<input type="password" name="new_password" required minlength="8" class="w-full">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Confirmer le nouveau mot de passe</label>
|
||||||
|
<input type="password" name="confirm_password" required minlength="8" class="w-full">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary w-full py-2 rounded-md">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a href="/logout" class="text-xs text-gray-500 hover:text-cyber-accent">Se déconnecter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -31,6 +31,21 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if ldap_enabled %}
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-400 block mb-1">Méthode</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
|
||||||
|
<input type="radio" name="auth_method" value="local" checked class="accent-cyan-500"> Locale
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-sm text-gray-300 cursor-pointer">
|
||||||
|
<input type="radio" name="auth_method" value="ldap" class="accent-cyan-500"> LDAP/AD
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="auth_method" value="local">
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
|
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>
|
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>
|
||||||
|
|||||||
@ -463,5 +463,79 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- iTop Contacts (filtre des teams à synchroniser) -->
|
||||||
|
{% if visible.itop_contacts %}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<button @click="open = open === 'itop_contacts' ? '' : 'itop_contacts'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400 font-bold">iTop Contacts — Périmètre</span>
|
||||||
|
<span class="badge badge-blue">Filtre des teams</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 text-lg" x-text="open === 'itop_contacts' ? '▼' : '▶'"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'itop_contacts'" class="border-t border-cyber-border p-4">
|
||||||
|
<form method="POST" action="/settings/itop_contacts" class="space-y-3">
|
||||||
|
{% for key, label, is_secret in sections.itop_contacts %}
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">{{ label }}</label>
|
||||||
|
<input type="text" name="{{ key }}" value="{{ vals[key] }}" placeholder="SecOps, iPOP, Externe, DSI, Admin DSI" class="w-full font-mono text-xs" {% if not editable.itop_contacts %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-xs text-gray-600 mt-2">
|
||||||
|
Seuls les contacts appartenant à ces teams iTop seront synchronisés dans PatchCenter. Si vide, défaut : SecOps, iPOP, Externe, DSI, Admin DSI.
|
||||||
|
</div>
|
||||||
|
{% if editable.itop_contacts %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- LDAP/AD -->
|
||||||
|
{% if visible.ldap %}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<button @click="open = open === 'ldap' ? '' : 'ldap'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400 font-bold">LDAP / Active Directory</span>
|
||||||
|
<span class="badge {% if vals.ldap_enabled == 'true' %}badge-green{% else %}badge-gray{% endif %}">{{ 'Activé' if vals.ldap_enabled == 'true' else 'Désactivé' }}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ vals.ldap_server or '' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 text-lg" x-text="open === 'ldap' ? '▼' : '▶'"></span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open === 'ldap'" class="border-t border-cyber-border p-4">
|
||||||
|
<form method="POST" action="/settings/ldap" class="space-y-3">
|
||||||
|
{% for key, label, is_secret in sections.ldap %}
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500">{{ label }}</label>
|
||||||
|
<input type="{{ 'password' if is_secret else 'text' }}" name="{{ key }}" value="{{ vals[key] }}" class="w-full" {% if not editable.ldap %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-xs text-gray-600 mt-2">
|
||||||
|
Une fois configuré et activé, le choix <b>Local / LDAP</b> apparaîtra sur la page de connexion. Les users peuvent aussi être forcés en LDAP via le champ "Auth" dans /users.
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
{% if editable.ldap %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
|
||||||
|
<button type="button" onclick="testLdap()" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Tester la connexion</button>
|
||||||
|
<span id="ldap-test-result" class="text-xs ml-2"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function testLdap() {
|
||||||
|
var out = document.getElementById('ldap-test-result');
|
||||||
|
out.textContent = 'Test en cours...';
|
||||||
|
out.className = 'text-xs ml-2 text-gray-400';
|
||||||
|
fetch('/settings/ldap/test', {method: 'POST', credentials: 'same-origin'})
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if (d.ok) { out.textContent = '✓ ' + (d.msg || 'OK'); out.className = 'text-xs ml-2 text-cyber-green'; }
|
||||||
|
else { out.textContent = '✗ ' + (d.msg || 'Erreur'); out.className = 'text-xs ml-2 text-cyber-red'; }
|
||||||
|
})
|
||||||
|
.catch(function(e){ out.textContent = '✗ ' + e.message; out.className = 'text-xs ml-2 text-cyber-red'; });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -1,136 +1,133 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}Utilisateurs{% endblock %}
|
{% block title %}Utilisateurs{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">Utilisateurs</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Gestion des comptes et profils — les utilisateurs proviennent des contacts iTop synchronisés</p>
|
||||||
|
</div>
|
||||||
|
{% if can_edit_users %}
|
||||||
|
<a href="/users/add" class="btn-primary px-4 py-2 text-sm">+ Ajouter un utilisateur{% if available_count %} <span class="text-xs opacity-70">({{ available_count }} contact{{ 's' if available_count > 1 else '' }} disponible{{ 's' if available_count > 1 else '' }})</span>{% endif %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if msg %}
|
{% if msg %}
|
||||||
<div class="mb-4 p-3 rounded text-sm {% if msg in ('forbidden','exists','exists_inactive','cant_self') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
<div class="mb-3 p-2 rounded text-sm {% if 'forbidden' in msg or 'error' in msg or 'cant' in msg or 'invalid' in msg or 'required' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
||||||
{% 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 %}
|
{% if msg == 'added' %}Utilisateur ajouté.
|
||||||
|
{% elif msg == 'exists' %}Cet utilisateur existe déjà.
|
||||||
|
{% elif msg == 'role_changed' %}Profil modifié.
|
||||||
|
{% elif msg == 'toggled' %}Statut modifié.
|
||||||
|
{% elif msg == 'password_changed' %}Mot de passe modifié.
|
||||||
|
{% elif msg == 'auth_changed' %}Méthode d'auth modifiée.
|
||||||
|
{% elif msg == 'deleted' %}Utilisateur supprimé.
|
||||||
|
{% elif msg == 'cant_self' %}Impossible sur votre propre compte.
|
||||||
|
{% elif msg == 'cant_demote_self' %}Vous ne pouvez pas vous rétrograder.
|
||||||
|
{% elif msg == 'cant_delete_admin' %}Le compte admin local ne peut pas être supprimé.
|
||||||
|
{% elif msg == 'forbidden' %}Permission refusée.
|
||||||
|
{% elif msg == 'invalid_role' %}Profil invalide.
|
||||||
|
{% elif msg == 'contact_required' %}Sélectionner un contact.
|
||||||
|
{% else %}{{ msg }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Liste utilisateurs -->
|
<!-- Legende des profils -->
|
||||||
<div x-data="{ editing: '', editUser: null }" class="space-y-3">
|
<div class="card p-4 mb-4">
|
||||||
{% for ud in users_data %}
|
<h3 class="text-sm font-bold text-cyber-accent mb-2">Profils</h3>
|
||||||
<div class="card overflow-hidden">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
|
||||||
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'">
|
{% for r in roles %}
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
|
<div class="font-bold text-cyber-accent">{{ profile_labels[r] }}</div>
|
||||||
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
|
<div class="text-gray-400 mt-1">{{ profile_descriptions[r] }}</div>
|
||||||
<span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{% if ud.user.role == "operator" %}intervenant{% else %}{{ ud.user.role }}{% endif %}</span>
|
|
||||||
<span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span>
|
|
||||||
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% for m in modules %}
|
|
||||||
{% if ud.perms.get(m) %}
|
|
||||||
<span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}" title="{{ m }}:{{ ud.perms[m] }}">{{ m[:3] }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '▼' : '▶'"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
|
<!-- Tableau users -->
|
||||||
{% if can_edit_users %}
|
<div class="card overflow-hidden">
|
||||||
<!-- Éditer infos user -->
|
<table class="w-full table-cyber text-xs">
|
||||||
<form method="POST" action="/users/{{ ud.user.id }}/edit" class="flex gap-3 items-end">
|
<thead><tr>
|
||||||
<div>
|
<th class="p-2 text-left">Utilisateur</th>
|
||||||
<label class="text-xs text-gray-500">Nom complet</label>
|
<th class="p-2">Profil</th>
|
||||||
<input type="text" name="display_name" value="{{ ud.user.display_name }}" class="text-xs py-1 px-2 w-40">
|
<th class="p-2">Auth</th>
|
||||||
</div>
|
<th class="p-2">Team iTop</th>
|
||||||
<div>
|
<th class="p-2">Email</th>
|
||||||
<label class="text-xs text-gray-500">Email</label>
|
<th class="p-2">Statut</th>
|
||||||
<input type="email" name="email" value="{{ ud.user.email or '' }}" class="text-xs py-1 px-2 w-44">
|
<th class="p-2">Dernier login</th>
|
||||||
</div>
|
<th class="p-2">Actions</th>
|
||||||
<div>
|
</tr></thead>
|
||||||
<label class="text-xs text-gray-500">Role</label>
|
<tbody>
|
||||||
<select name="role" class="text-xs py-1 px-2">
|
{% for u in users %}
|
||||||
{% for r in ['admin','coordinator','operator','viewer'] %}
|
<tr class="border-t border-cyber-border/30 {% if not u.is_active %}opacity-50{% endif %}">
|
||||||
<option value="{{ r }}" {% if r == ud.user.role %}selected{% endif %}>{% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}</option>
|
<td class="p-2">
|
||||||
|
<div class="font-mono font-bold">{{ u.username }}</div>
|
||||||
|
<div class="text-gray-400">{{ u.display_name or '' }}</div>
|
||||||
|
{% if u.force_password_change %}<div class="text-cyber-yellow" style="font-size:9px">Doit changer mdp</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
{% if can_edit_users %}
|
||||||
|
<form method="POST" action="/users/{{ u.id }}/role" style="display:inline">
|
||||||
|
<select name="role" onchange="this.form.submit()" class="text-xs py-1 px-2">
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ profile_labels[r] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-sm bg-cyber-accent text-black">Modifier</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Permissions par module -->
|
|
||||||
<form method="POST" action="/users/{{ ud.user.id }}/permissions">
|
|
||||||
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4>
|
|
||||||
<div class="grid grid-cols-8 gap-2">
|
|
||||||
{% for m in modules %}
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
|
|
||||||
<select name="perm_{{ m }}" class="w-full text-xs py-1">
|
|
||||||
<option value="">—</option>
|
|
||||||
{% for l in levels %}
|
|
||||||
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-1 text-sm mt-2">Sauvegarder permissions</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex gap-3 pt-2 border-t border-cyber-border items-center">
|
|
||||||
<form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center">
|
|
||||||
<input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48">
|
|
||||||
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
|
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/users/{{ ud.user.id }}/toggle">
|
{% else %}
|
||||||
<button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
|
<span class="badge badge-blue">{{ profile_labels[u.role] or u.role }}</span>
|
||||||
{{ 'Désactiver' if ud.user.is_active else 'Activer' }}
|
{% endif %}
|
||||||
</button>
|
</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
{% if can_edit_users %}
|
||||||
|
<form method="POST" action="/users/{{ u.id }}/auth_type" style="display:inline">
|
||||||
|
<select name="auth_type" onchange="this.form.submit()" class="text-xs py-1 px-2">
|
||||||
|
<option value="local" {% if u.auth_type == 'local' %}selected{% endif %}>Local</option>
|
||||||
|
<option value="ldap" {% if u.auth_type == 'ldap' %}selected{% endif %}>LDAP</option>
|
||||||
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/users/{{ ud.user.id }}/delete">
|
{% else %}
|
||||||
<button type="submit" class="btn-sm bg-red-900/50 text-cyber-red" onclick="return confirm('SUPPRIMER définitivement {{ ud.user.username }} ?')">Supprimer</button>
|
<span class="badge {% if u.auth_type == 'ldap' %}badge-blue{% else %}badge-gray{% endif %}">{{ u.auth_type }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 text-center text-gray-400">
|
||||||
|
{% if u.contact_team %}<span class="badge badge-gray">{{ u.contact_team }}</span>{% else %}<span class="text-gray-600">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="p-2 text-gray-400">{{ u.email or '—' }}</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
<span class="badge {% if u.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if u.is_active else 'Inactif' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-2 text-center text-gray-400" style="font-size:10px">{% if u.last_login %}{{ u.last_login.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
{% if can_edit_users %}
|
||||||
|
<form method="POST" action="/users/{{ u.id }}/toggle" style="display:inline">
|
||||||
|
<button type="submit" class="text-xs {% if u.is_active %}text-cyber-yellow{% else %}text-cyber-green{% endif %} hover:underline">{{ 'Désactiver' if u.is_active else 'Activer' }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<button onclick="document.getElementById('pw-{{ u.id }}').style.display='table-row'" class="text-xs text-gray-400 hover:text-cyber-accent ml-2">Mdp</button>
|
||||||
{% else %}
|
{% if can_admin_users and u.username != 'admin' %}
|
||||||
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
|
<form method="POST" action="/users/{{ u.id }}/delete" style="display:inline" onsubmit="return confirm('Supprimer {{ u.username }} ?')">
|
||||||
{% endif %}
|
<button type="submit" class="text-xs text-cyber-red hover:underline ml-2">Supprimer</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if can_edit_users %}
|
||||||
|
<tr id="pw-{{ u.id }}" style="display:none" class="bg-cyber-border/20">
|
||||||
|
<td colspan="8" class="p-3">
|
||||||
|
<form method="POST" action="/users/{{ u.id }}/password" class="flex gap-2 items-center">
|
||||||
|
<label class="text-xs text-gray-400">Nouveau mot de passe pour <b>{{ u.username }}</b> :</label>
|
||||||
|
<input type="password" name="new_password" required minlength="6" class="text-xs py-1 px-2 flex-1">
|
||||||
|
<label class="text-xs text-gray-400 flex items-center gap-1">
|
||||||
|
<input type="checkbox" name="force_change" checked> Doit changer au 1er login
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
|
||||||
|
<button type="button" onclick="document.getElementById('pw-{{ u.id }}').style.display='none'" class="text-xs text-gray-500">Annuler</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ajouter un utilisateur -->
|
|
||||||
{% if can_edit_users %}
|
|
||||||
<div class="card p-5 mt-6">
|
|
||||||
<h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3>
|
|
||||||
<form method="POST" action="/users/add" class="space-y-3">
|
|
||||||
<div class="grid grid-cols-4 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-gray-500">Username</label>
|
|
||||||
<input type="text" name="new_username" required class="w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-gray-500">Nom complet</label>
|
|
||||||
<input type="text" name="new_display_name" required class="w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-gray-500">Email</label>
|
|
||||||
<input type="email" name="new_email" class="w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-xs text-gray-500">Role</label>
|
|
||||||
<select name="new_role" class="w-full">
|
|
||||||
<option value="operator">intervenant</option>
|
|
||||||
<option value="coordinator">coordinator</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-64">
|
|
||||||
<label class="text-xs text-gray-500">Mot de passe</label>
|
|
||||||
<input type="password" name="new_password" required class="w-full">
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-600">Permissions pre-remplies selon le role. Modifiables ensuite.</p>
|
|
||||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Créer</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
122
app/templates/users_add.html
Normal file
122
app/templates/users_add.html
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Ajouter un utilisateur{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/users" class="text-xs text-gray-500 hover:text-gray-300">← Retour utilisateurs</a>
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">Ajouter un utilisateur</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Choisir un contact iTop puis définir son profil PatchCenter</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
|
||||||
|
<b class="text-cyber-accent">Périmètre :</b> seuls les contacts synchronisés depuis iTop (Teams SecOps, iPOP, Externe, DSI, Admin DSI) apparaissent ici.
|
||||||
|
Si la personne n'est pas dans la liste, elle doit d'abord être créée dans iTop par un admin DSI.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres -->
|
||||||
|
<div class="card p-3 mb-4">
|
||||||
|
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||||
|
<input type="text" name="search" value="{{ search }}" placeholder="Rechercher nom ou email..." class="text-xs py-1 px-2" style="width:250px">
|
||||||
|
<select name="team" class="text-xs py-1 px-2" style="width:150px">
|
||||||
|
<option value="">Toutes teams</option>
|
||||||
|
{% for t in teams %}
|
||||||
|
<option value="{{ t.team }}" {% if team_filter == t.team %}selected{% endif %}>{{ t.team }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||||
|
<a href="/users/add" class="text-xs text-gray-400 hover:text-cyber-accent">Reset</a>
|
||||||
|
<span class="text-xs text-gray-500 ml-auto">{{ contacts|length }} contact{{ 's' if contacts|length > 1 else '' }}</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not contacts %}
|
||||||
|
<div class="card p-6 text-center text-gray-500">
|
||||||
|
<p>Aucun contact disponible pour créer un utilisateur.</p>
|
||||||
|
<p class="text-xs mt-2">Lancer une synchro depuis iTop dans <a href="/referentiel" class="text-cyber-accent">Référentiel</a>.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Tableau contacts -->
|
||||||
|
<form method="POST" action="/users/add">
|
||||||
|
<div class="card overflow-hidden mb-4">
|
||||||
|
<table class="w-full table-cyber text-xs">
|
||||||
|
<thead><tr>
|
||||||
|
<th class="p-2 w-8"></th>
|
||||||
|
<th class="p-2 text-left">Nom</th>
|
||||||
|
<th class="p-2 text-left">Email</th>
|
||||||
|
<th class="p-2">Team iTop</th>
|
||||||
|
<th class="p-2 text-left">Fonction</th>
|
||||||
|
<th class="p-2 text-left">Téléphone</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in contacts %}
|
||||||
|
<tr class="border-t border-cyber-border/30 hover:bg-cyber-hover cursor-pointer" onclick="selectContact({{ c.id }}, '{{ c.name|e }}', '{{ c.team|default('') }}')">
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
<input type="radio" name="contact_id" value="{{ c.id }}" id="c{{ c.id }}">
|
||||||
|
</td>
|
||||||
|
<td class="p-2"><label for="c{{ c.id }}" class="cursor-pointer font-mono">{{ c.name }}</label></td>
|
||||||
|
<td class="p-2 text-gray-400">{{ c.email }}</td>
|
||||||
|
<td class="p-2 text-center">{% if c.team %}<span class="badge badge-gray">{{ c.team }}</span>{% else %}—{% endif %}</td>
|
||||||
|
<td class="p-2 text-gray-400">{{ c.function or '' }}</td>
|
||||||
|
<td class="p-2 text-gray-400">{{ c.telephone or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire de création -->
|
||||||
|
<div class="card p-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-3">Paramètres du compte <span id="selected-name" class="text-gray-400 font-normal ml-2"></span></h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500 block mb-1">Profil (rôle PatchCenter)</label>
|
||||||
|
<select name="role" id="role-select" class="w-full text-sm">
|
||||||
|
{% for r in roles %}
|
||||||
|
<option value="{{ r }}">{{ profile_labels[r] }} — {{ profile_descriptions[r][:60] }}...</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500 block mb-1">Type d'authentification</label>
|
||||||
|
<select name="auth_type" class="w-full text-sm">
|
||||||
|
<option value="local">Local (mot de passe stocké)</option>
|
||||||
|
<option value="ldap">LDAP/AD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500 block mb-1">Nom d'utilisateur (optionnel, dérivé de l'email sinon)</label>
|
||||||
|
<input type="text" name="username" class="w-full text-sm" placeholder="ex: jean.dupont">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-gray-500 block mb-1">Mot de passe initial (optionnel)</label>
|
||||||
|
<input type="password" name="password" class="w-full text-sm" placeholder="Laisser vide pour générer">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="text-xs text-gray-400 flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="force_change" checked> Forcer le changement de mot de passe au 1er login
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary px-4 py-2 text-sm" id="submit-btn" disabled>Créer l'utilisateur</button>
|
||||||
|
<a href="/users" class="text-xs text-gray-500 hover:text-cyber-accent self-center ml-2">Annuler</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function selectContact(id, name, team) {
|
||||||
|
document.getElementById('c' + id).checked = true;
|
||||||
|
document.getElementById('selected-name').textContent = '— ' + name + (team ? ' (' + team + ')' : '');
|
||||||
|
document.getElementById('submit-btn').disabled = false;
|
||||||
|
// Auto-sélection du profil suggéré selon team
|
||||||
|
var roleSel = document.getElementById('role-select');
|
||||||
|
if (team === 'SecOps') roleSel.value = 'operator';
|
||||||
|
else if (team === 'iPOP') roleSel.value = 'coordinator';
|
||||||
|
else if (team === 'Externe') roleSel.value = 'viewer';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
15
migrate_applications.sql
Normal file
15
migrate_applications.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Ajoute itop_id à applications
|
||||||
|
ALTER TABLE applications ADD COLUMN IF NOT EXISTS itop_id INTEGER;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS applications_itop_id_unique ON applications (itop_id) WHERE itop_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Ajoute criticite status si besoin
|
||||||
|
ALTER TABLE applications ADD COLUMN IF NOT EXISTS status VARCHAR(30);
|
||||||
|
|
||||||
|
-- Vide les données applicatives actuelles côté serveurs
|
||||||
|
UPDATE servers SET application_id = NULL, application_name = NULL WHERE application_id IS NOT NULL OR application_name IS NOT NULL;
|
||||||
|
|
||||||
|
-- Vide la table applications (on repart de zéro depuis iTop)
|
||||||
|
DELETE FROM applications;
|
||||||
|
|
||||||
|
SELECT 'applications vidée' as msg;
|
||||||
|
SELECT COUNT(*) as servers_cleared FROM servers WHERE application_id IS NULL AND application_name IS NULL;
|
||||||
7
migrate_etat.sql
Normal file
7
migrate_etat.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE servers DROP CONSTRAINT IF EXISTS servers_etat_check;
|
||||||
|
UPDATE servers SET etat = 'production' WHERE etat = 'en_production';
|
||||||
|
UPDATE servers SET etat = 'implementation' WHERE etat IN ('en_implementation', 'en_cours');
|
||||||
|
UPDATE servers SET etat = 'obsolete' WHERE etat IN ('decommissionne', 'en_decommissionnement', 'eteint', 'eol');
|
||||||
|
ALTER TABLE servers ADD CONSTRAINT servers_etat_check CHECK (etat IN ('production', 'implementation', 'stock', 'obsolete'));
|
||||||
|
ALTER TABLE servers ALTER COLUMN etat SET DEFAULT 'production';
|
||||||
|
SELECT etat, COUNT(*) FROM servers GROUP BY etat ORDER BY 2 DESC;
|
||||||
23
migrate_users.sql
Normal file
23
migrate_users.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-- Link users to iTop Person
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS itop_person_id INTEGER;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_itop_sync TIMESTAMP;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
-- source already implied by auth_type, no change
|
||||||
|
|
||||||
|
-- Link contacts to iTop Person
|
||||||
|
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS itop_id INTEGER;
|
||||||
|
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS telephone VARCHAR(50);
|
||||||
|
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS team VARCHAR(100);
|
||||||
|
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS function VARCHAR(200);
|
||||||
|
|
||||||
|
-- Unique constraint on email for linking users
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_active ON users (LOWER(email)) WHERE email IS NOT NULL AND email != '';
|
||||||
|
|
||||||
|
-- Match existing users to contacts by email
|
||||||
|
UPDATE users u SET itop_person_id = c.itop_id
|
||||||
|
FROM contacts c
|
||||||
|
WHERE u.email IS NOT NULL AND u.email != '' AND LOWER(u.email) = LOWER(c.email) AND c.itop_id IS NOT NULL;
|
||||||
|
|
||||||
|
SELECT COUNT(*) as users_linked FROM users WHERE itop_person_id IS NOT NULL;
|
||||||
|
SELECT COUNT(*) as users_total FROM users;
|
||||||
52
replace_etat.py
Normal file
52
replace_etat.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Remplace les anciennes valeurs d'etat par les nouvelles (iTop) dans tous les fichiers Python et templates."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Ordre important: plus specifiques d'abord
|
||||||
|
REPLACEMENTS = [
|
||||||
|
("en_decommissionnement", "obsolete"),
|
||||||
|
("en_implementation", "implementation"),
|
||||||
|
("en_production", "production"),
|
||||||
|
("decommissionne", "obsolete"),
|
||||||
|
("en_cours", "implementation"), # prudent: seulement si etat context
|
||||||
|
("'eteint'", "'obsolete'"),
|
||||||
|
('"eteint"', '"obsolete"'),
|
||||||
|
("'eol'", "'obsolete'"),
|
||||||
|
('"eol"', '"obsolete"'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ne PAS toucher:
|
||||||
|
# - itop_service.py (mappings deja corrects, on les laisse pour rétro-compat)
|
||||||
|
# - migrate_etat.sql (script de migration)
|
||||||
|
# - les tests
|
||||||
|
SKIP_FILES = {
|
||||||
|
"itop_service.py", # contient le mapping historique
|
||||||
|
}
|
||||||
|
|
||||||
|
ROOT = "app"
|
||||||
|
count = 0
|
||||||
|
for root, dirs, files in os.walk(ROOT):
|
||||||
|
for f in files:
|
||||||
|
if not f.endswith((".py", ".html")):
|
||||||
|
continue
|
||||||
|
if f in SKIP_FILES:
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, f)
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
orig = content
|
||||||
|
for old, new in REPLACEMENTS:
|
||||||
|
# en_cours est ambigu : on ne remplace que dans contexte etat
|
||||||
|
if old == "en_cours":
|
||||||
|
content = re.sub(r"(etat\s*[=:]\s*['\"])en_cours(['\"])", r"\1implementation\2", content)
|
||||||
|
content = re.sub(r"\betat\s*==\s*['\"]en_cours['\"]", "etat == 'implementation'", content)
|
||||||
|
continue
|
||||||
|
content = content.replace(old, new)
|
||||||
|
if content != orig:
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
print(f" modified: {path}")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
print(f"\nTotal: {count} files modified")
|
||||||
Loading…
Reference in New Issue
Block a user