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):
|
||||
"""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'}"""
|
||||
if not user:
|
||||
return {}
|
||||
uid = user.get("uid")
|
||||
if not uid:
|
||||
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"
|
||||
), {"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):
|
||||
|
||||
30
app/main.py
30
app/main.py
@ -5,23 +5,42 @@ from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import APP_NAME, APP_VERSION
|
||||
from .dependencies import get_current_user, get_user_perms
|
||||
from .database import SessionLocal
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
|
||||
from .database import SessionLocal, SessionLocalDemo
|
||||
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching
|
||||
|
||||
|
||||
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):
|
||||
user = get_current_user(request)
|
||||
perms = {}
|
||||
must_change_pwd = False
|
||||
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:
|
||||
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:
|
||||
db.close()
|
||||
request.state.user = user
|
||||
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)
|
||||
return response
|
||||
|
||||
@ -41,10 +60,9 @@ app.include_router(specifics.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(contacts.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(referentiel.router)
|
||||
app.include_router(patching.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -6,6 +6,7 @@ from ..dependencies import get_current_user
|
||||
from ..database import SessionLocal, SessionLocalDemo
|
||||
from ..auth import verify_password, create_access_token, hash_password
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@ -13,41 +14,62 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ldap_ok = ldap_enabled(db)
|
||||
finally:
|
||||
db.close()
|
||||
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")
|
||||
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
|
||||
factory = SessionLocalDemo if mode == "demo" else SessionLocal
|
||||
db = factory()
|
||||
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()
|
||||
|
||||
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:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
|
||||
})
|
||||
return err_template("Utilisateur inconnu")
|
||||
if not row.is_active:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Compte desactive"
|
||||
})
|
||||
try:
|
||||
ok = verify_password(password, row.password_hash)
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
log_login_failed(db, request, username)
|
||||
db.commit()
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
|
||||
})
|
||||
return err_template("Compte desactive")
|
||||
|
||||
# Choix de la methode d'auth
|
||||
use_ldap = (auth_method == "ldap") or (row.auth_type == "ldap")
|
||||
if use_ldap and not ldap_is_on:
|
||||
return err_template("LDAP non active")
|
||||
|
||||
if use_ldap:
|
||||
result = ldap_authenticate(db, username, password)
|
||||
if not result.get("ok"):
|
||||
log_login_failed(db, request, username)
|
||||
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
|
||||
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}
|
||||
|
||||
@ -75,6 +75,20 @@ SECTIONS = {
|
||||
("teams_sp_client_secret", "App Client Secret", True),
|
||||
("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"]},
|
||||
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
|
||||
"itop": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"itop_contacts": {"visible": ["admin"], "editable": ["admin"]},
|
||||
"ldap": {"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)
|
||||
|
||||
|
||||
@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 ---
|
||||
|
||||
@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.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
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 ..services.profile_service import PROFILES, PROFILE_LABELS, PROFILE_DESCRIPTIONS
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users", "planning", "specifics"]
|
||||
LEVELS = ["view", "edit", "admin"]
|
||||
# Profils disponibles (keys du PROFILES dict)
|
||||
ROLES = ["admin", "coordinator", "operator", "viewer"]
|
||||
|
||||
|
||||
def _get_users_with_perms(db):
|
||||
users = db.execute(text(
|
||||
"SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username"
|
||||
)).fetchall()
|
||||
result = []
|
||||
for u in users:
|
||||
perms = {}
|
||||
rows = db.execute(text(
|
||||
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
|
||||
), {"uid": u.id}).fetchall()
|
||||
for r in rows:
|
||||
perms[r.module] = r.level
|
||||
result.append({"user": u, "perms": perms})
|
||||
return result
|
||||
def _get_users(db):
|
||||
users = db.execute(text("""
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.role, u.auth_type,
|
||||
u.is_active, u.last_login, u.itop_person_id, u.force_password_change,
|
||||
c.team as contact_team, c.function as contact_function
|
||||
FROM users u
|
||||
LEFT JOIN contacts c ON c.itop_id = u.itop_person_id
|
||||
ORDER BY u.is_active DESC, u.username
|
||||
""")).fetchall()
|
||||
return users
|
||||
|
||||
|
||||
def _check_access(request, db):
|
||||
def _check_access(request, db, require_edit=False):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return None, None, RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "users"):
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
async def users_page(request: Request, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
if redirect:
|
||||
return redirect
|
||||
users_data = _get_users_with_perms(db)
|
||||
|
||||
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.update({
|
||||
"app_name": APP_NAME, "users_data": users_data,
|
||||
"modules": MODULES, "levels": LEVELS,
|
||||
"app_name": APP_NAME, "users": users,
|
||||
"available_count": available_count,
|
||||
"roles": ROLES, "profile_labels": PROFILE_LABELS,
|
||||
"profile_descriptions": PROFILE_DESCRIPTIONS,
|
||||
"can_edit_users": can_edit(perms, "users"),
|
||||
"can_admin_users": can_admin(perms, "users"),
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("users.html", ctx)
|
||||
|
||||
|
||||
@router.post("/users/add")
|
||||
async def user_add(request: Request, db=Depends(get_db),
|
||||
new_username: str = Form(...), new_display_name: str = Form(...),
|
||||
new_email: str = Form(""), new_password: str = Form(...),
|
||||
new_role: str = Form("operator")):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
@router.get("/users/add", response_class=HTMLResponse)
|
||||
async def users_add_page(request: Request, db=Depends(get_db),
|
||||
search: str = "", team: str = ""):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
|
||||
# Verifier si username existe deja
|
||||
existing = db.execute(text("SELECT id, is_active FROM users WHERE LOWER(username) = LOWER(:u)"),
|
||||
{"u": new_username.strip()}).fetchone()
|
||||
# Lister les contacts iTop non-encore-users
|
||||
where = ["c.itop_id IS NOT NULL", "c.is_active = true",
|
||||
"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 not existing.is_active:
|
||||
return RedirectResponse(url=f"/users?msg=exists_inactive", status_code=303)
|
||||
return RedirectResponse(url=f"/users?msg=exists", status_code=303)
|
||||
return RedirectResponse(url="/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("""
|
||||
INSERT INTO users (username, display_name, email, password_hash, role)
|
||||
VALUES (:u, :dn, :e, :ph, :r)
|
||||
"""), {"u": new_username.strip(), "dn": new_display_name, "e": new_email or None,
|
||||
"ph": pw_hash, "r": new_role})
|
||||
|
||||
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username.strip()}).fetchone()
|
||||
if row:
|
||||
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})
|
||||
|
||||
INSERT INTO users (username, display_name, email, password_hash, role,
|
||||
auth_type, itop_person_id, is_active, force_password_change)
|
||||
VALUES (:u, :dn, :e, :ph, :r, :at, :iid, true, :fpc)
|
||||
"""), {
|
||||
"u": username.strip(), "dn": contact.name, "e": contact.email,
|
||||
"ph": pw_hash, "r": role, "at": auth_type,
|
||||
"iid": contact.itop_id, "fpc": fpc,
|
||||
})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=added", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/permissions")
|
||||
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
@router.post("/users/{user_id}/role")
|
||||
async def user_change_role(request: Request, user_id: int, db=Depends(get_db),
|
||||
role: str = Form(...)):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
|
||||
form = await request.form()
|
||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
||||
for mod in MODULES:
|
||||
lvl = form.get(f"perm_{mod}", "")
|
||||
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})
|
||||
if role not in ROLES:
|
||||
return RedirectResponse(url="/users?msg=invalid_role", status_code=303)
|
||||
# Empêche un admin de se rétrograder lui-même
|
||||
if user_id == user.get("uid") and role != "admin":
|
||||
return RedirectResponse(url="/users?msg=cant_demote_self", status_code=303)
|
||||
db.execute(text("UPDATE users SET role=:r, updated_at=NOW() WHERE id=:id"),
|
||||
{"r": role, "id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/users?msg=perms_saved", 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)
|
||||
return RedirectResponse(url="/users?msg=role_changed", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/toggle")
|
||||
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:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
# Empecher de se desactiver soi-meme
|
||||
if user_id == user.get("uid"):
|
||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
||||
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id})
|
||||
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id=:id"), {"id": user_id})
|
||||
db.commit()
|
||||
return RedirectResponse(url="/users?msg=toggled", status_code=303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
async def user_password(request: Request, user_id: int, db=Depends(get_db),
|
||||
new_password: str = Form(...)):
|
||||
user, perms, redirect = _check_access(request, db)
|
||||
new_password: str = Form(...),
|
||||
force_change: str = Form("")):
|
||||
user, perms, redirect = _check_access(request, db, require_edit=True)
|
||||
if redirect:
|
||||
return redirect
|
||||
if not can_edit(perms, "users"):
|
||||
return RedirectResponse(url="/users?msg=forbidden", status_code=303)
|
||||
pw_hash = hash_password(new_password)
|
||||
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
|
||||
{"ph": pw_hash, "id": user_id})
|
||||
fpc = (force_change == "on")
|
||||
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()
|
||||
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")
|
||||
async def user_delete(request: Request, user_id: int, db=Depends(get_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)
|
||||
if user_id == user.get("uid"):
|
||||
return RedirectResponse(url="/users?msg=cant_self", status_code=303)
|
||||
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
|
||||
db.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id})
|
||||
# Protéger l'admin local (id=1)
|
||||
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()
|
||||
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"})
|
||||
|
||||
|
||||
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):
|
||||
if not ip:
|
||||
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"),
|
||||
{"sid": server_id, "ip": ip}).fetchone()
|
||||
if not existing:
|
||||
try:
|
||||
db.execute(text(
|
||||
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
||||
{"sid": server_id, "ip": ip})
|
||||
except Exception:
|
||||
pass
|
||||
if exact:
|
||||
return
|
||||
try:
|
||||
db.execute(text(
|
||||
"INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh, description) VALUES (:sid, :ip, 'primary', true, 'itop')"),
|
||||
{"sid": server_id, "ip": ip})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _save_sync_timestamp(db, direction, stats):
|
||||
@ -154,11 +202,20 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# ─── 5. Contacts + Teams ───
|
||||
persons = client.get_all("Person", "name,first_name,email,phone,org_name")
|
||||
# ─── 5. Contacts + Teams (filtre périmètre IT uniquement) ───
|
||||
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")
|
||||
for t in teams:
|
||||
team_name = t.get("name", "")
|
||||
@ -167,29 +224,67 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass):
|
||||
if pname:
|
||||
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:
|
||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||
email = p.get("email", "")
|
||||
if not email:
|
||||
continue
|
||||
# Determine role from team
|
||||
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")
|
||||
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()
|
||||
if existing:
|
||||
db.execute(text("UPDATE contacts SET name=:n, role=:r, updated_at=NOW() WHERE id=:id"),
|
||||
{"id": existing.id, "n": fullname, "r": role})
|
||||
db.execute(text("""UPDATE contacts SET name=:n, role=:r, itop_id=:iid,
|
||||
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:
|
||||
try:
|
||||
db.execute(text("INSERT INTO contacts (name, email, role) VALUES (:n, :e, :r)"),
|
||||
{"n": fullname, "e": email, "r": role})
|
||||
db.execute(text("""INSERT INTO contacts (name, email, role, itop_id,
|
||||
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
|
||||
except Exception:
|
||||
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 ───
|
||||
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()}
|
||||
@ -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": {},
|
||||
"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 ───
|
||||
vms = client.get_all("VirtualMachine",
|
||||
"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,"
|
||||
"tier_name,connexion_method_name,ssh_user_name,"
|
||||
"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",
|
||||
"implementation": "en_cours", "obsolete": "decommissionne"}
|
||||
# PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete)
|
||||
itop_status = {"production": "production", "stock": "stock",
|
||||
"implementation": "implementation", "obsolete": "obsolete"}
|
||||
|
||||
for v in vms:
|
||||
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_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 = {
|
||||
"hostname": hostname, "fqdn": v.get("name", hostname),
|
||||
"os_family": "linux" if "linux" in v.get("osfamily_id_friendlyname", "").lower() else "windows",
|
||||
"os_version": v.get("osversion_id_friendlyname", ""),
|
||||
"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,
|
||||
"resp_srv": resp_srv_name,
|
||||
"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", ""),
|
||||
"domain_ltd": v.get("domain_ldap_name", ""),
|
||||
"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()
|
||||
@ -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,
|
||||
patch_frequency=:patch_freq, patch_excludes=:patch_excludes,
|
||||
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})
|
||||
if 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()
|
||||
if not hostname:
|
||||
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()
|
||||
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:
|
||||
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,
|
||||
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')"""),
|
||||
{"h": hostname, "f": s.get("name", hostname),
|
||||
"osf": "linux" if "linux" in s.get("osfamily_id_friendlyname", "").lower() else "windows",
|
||||
"osv": s.get("osversion_id_friendlyname", ""),
|
||||
{"h": hostname, "f": s.get("name", hostname), "osf": osf, "osv": osv,
|
||||
"resp": resp, "desc": s.get("description", ""),
|
||||
"site": s.get("location_name", "")})
|
||||
db.flush()
|
||||
ip = s.get("managementip", "")
|
||||
if ip:
|
||||
new_srv = db.execute(text("SELECT id FROM servers WHERE hostname=:h"), {"h": hostname}).fetchone()
|
||||
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"):
|
||||
itop_vms[v["name"].split(".")[0].lower()] = v
|
||||
|
||||
status_map = {"en_production": "production", "decommissionne": "obsolete",
|
||||
"stock": "stock", "en_cours": "implementation"}
|
||||
status_map = {"production": "production", "implementation": "implementation",
|
||||
"stock": "stock", "obsolete": "obsolete"}
|
||||
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
|
||||
itop_persons = {}
|
||||
for p in client.get_all("Person", "name,first_name"):
|
||||
fullname = f"{p.get('first_name','')} {p.get('name','')}".strip()
|
||||
itop_persons[fullname.lower()] = p["itop_id"]
|
||||
|
||||
rows = db.execute(text("""SELECT hostname, fqdn, os_version, etat, commentaire, tier,
|
||||
ssh_method, ssh_user, patch_frequency, patch_excludes, domain_ltd,
|
||||
pref_patch_jour, pref_patch_heure, responsable_nom, referent_nom
|
||||
FROM servers WHERE machine_type='vm'""")).fetchall()
|
||||
rows = db.execute(text("""SELECT s.hostname, s.fqdn, s.os_version, s.os_family, s.etat, s.commentaire, s.tier,
|
||||
s.ssh_method, s.ssh_user, s.patch_frequency, s.patch_excludes, s.domain_ltd,
|
||||
s.pref_patch_jour, s.pref_patch_heure, s.responsable_nom, s.referent_nom,
|
||||
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:
|
||||
hostname = (srv.hostname or "").lower()
|
||||
itop_vm = itop_vms.get(hostname)
|
||||
|
||||
fields = {}
|
||||
if srv.mgmt_ip:
|
||||
fields["managementip"] = srv.mgmt_ip.split("/")[0]
|
||||
if srv.etat:
|
||||
fields["status"] = status_map.get(srv.etat, "production")
|
||||
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
|
||||
if 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
|
||||
if srv.responsable_nom:
|
||||
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; }
|
||||
.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); } }
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
|
||||
</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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% set path = request.url.path %}
|
||||
|
||||
{% 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 %}
|
||||
{% 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.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 %}
|
||||
{% 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.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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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.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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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.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 %}
|
||||
{# Dashboard principal #}
|
||||
{% 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 %}
|
||||
|
||||
{# Serveurs (groupe repliable avec Correspondance) #}
|
||||
{% if p.servers %}
|
||||
<div>
|
||||
<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">
|
||||
<span>Serveurs</span>
|
||||
<span x-text="open === 'servers' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<div x-show="open === 'servers'" x-cloak class="space-y-1 pl-1">
|
||||
<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.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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== PATCHING (groupe repliable) ===== #}
|
||||
{% if p.campaigns or p.planning or p.quickwin %}
|
||||
<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>
|
||||
</aside>
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
<p class="text-center text-xs text-gray-600 mt-4">SANEF — Direction des Systèmes d'Information</p>
|
||||
|
||||
@ -463,5 +463,79 @@
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
<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 %}
|
||||
|
||||
@ -1,136 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Utilisateurs{% endblock %}
|
||||
{% 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 %}
|
||||
<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 %}">
|
||||
{% 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 %}
|
||||
<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 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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste utilisateurs -->
|
||||
<div x-data="{ editing: '', editUser: null }" class="space-y-3">
|
||||
{% for ud in users_data %}
|
||||
<div class="card overflow-hidden">
|
||||
<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 }}'">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
|
||||
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
|
||||
<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>
|
||||
<!-- Legende des profils -->
|
||||
<div class="card p-4 mb-4">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Profils</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 text-xs">
|
||||
{% for r in roles %}
|
||||
<div>
|
||||
<div class="font-bold text-cyber-accent">{{ profile_labels[r] }}</div>
|
||||
<div class="text-gray-400 mt-1">{{ profile_descriptions[r] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
|
||||
{% if can_edit_users %}
|
||||
<!-- Éditer infos user -->
|
||||
<form method="POST" action="/users/{{ ud.user.id }}/edit" class="flex gap-3 items-end">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Nom complet</label>
|
||||
<input type="text" name="display_name" value="{{ ud.user.display_name }}" class="text-xs py-1 px-2 w-40">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Email</label>
|
||||
<input type="email" name="email" value="{{ ud.user.email or '' }}" class="text-xs py-1 px-2 w-44">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500">Role</label>
|
||||
<select name="role" class="text-xs py-1 px-2">
|
||||
{% for r in ['admin','coordinator','operator','viewer'] %}
|
||||
<option value="{{ r }}" {% if r == ud.user.role %}selected{% endif %}>{% if r == "operator" %}intervenant{% else %}{{ r }}{% endif %}</option>
|
||||
<!-- Tableau users -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 text-left">Utilisateur</th>
|
||||
<th class="p-2">Profil</th>
|
||||
<th class="p-2">Auth</th>
|
||||
<th class="p-2">Team iTop</th>
|
||||
<th class="p-2">Email</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Dernier login</th>
|
||||
<th class="p-2">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr class="border-t border-cyber-border/30 {% if not u.is_active %}opacity-50{% endif %}">
|
||||
<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 %}
|
||||
</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 method="POST" action="/users/{{ ud.user.id }}/toggle">
|
||||
<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 %}">
|
||||
{{ 'Désactiver' if ud.user.is_active else 'Activer' }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="badge badge-blue">{{ profile_labels[u.role] or u.role }}</span>
|
||||
{% endif %}
|
||||
</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 method="POST" action="/users/{{ ud.user.id }}/delete">
|
||||
<button type="submit" class="btn-sm bg-red-900/50 text-cyber-red" onclick="return confirm('SUPPRIMER définitivement {{ ud.user.username }} ?')">Supprimer</button>
|
||||
{% else %}
|
||||
<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>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500">Permissions en lecture seule</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<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>
|
||||
{% if can_admin_users and u.username != 'admin' %}
|
||||
<form method="POST" action="/users/{{ u.id }}/delete" style="display:inline" onsubmit="return confirm('Supprimer {{ u.username }} ?')">
|
||||
<button type="submit" class="text-xs text-cyber-red hover:underline ml-2">Supprimer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
|
||||
<!-- 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 %}
|
||||
|
||||
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