Add phase 5 secure credential vault
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.core.templating import templates
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.credential_vault.models import CredentialVaultAccessLog, CredentialVaultEntry, CredentialVaultVersion
|
||||
from app.modules.credential_vault.service import active_branch_id, active_tenant_id, can_manage_vault, can_open_vault, can_view_entry, create_entry, due_state, list_visible_entries, log_access, reveal_entry, rotate_entry
|
||||
from app.modules.registrations.models import ClientRegistration
|
||||
|
||||
router = APIRouter(prefix="/credential-vault", tags=["credential-vault-ui"])
|
||||
|
||||
|
||||
def _date(value: str) -> date | None:
|
||||
return date.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _ctx(request: Request, user: User, db, **kwargs):
|
||||
data = {"request": request, "current_user": user, "current_user_roles": get_user_roles(db, user.id), "current_user_permissions": get_user_permissions(db, user.id), "csrf_token": get_or_create_csrf_token(request)}
|
||||
data.update(kwargs)
|
||||
return data
|
||||
|
||||
|
||||
def _user(request: Request, db) -> User:
|
||||
user = get_current_user(request, db)
|
||||
if not user:
|
||||
raise HTTPException(401, "Login required")
|
||||
if not can_open_vault(db, user):
|
||||
raise HTTPException(403, "Credential vault access is not enabled for this role.")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, include_archived: bool = False):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); tenant_id = active_tenant_id(request, user); branch_id = active_branch_id(request, user)
|
||||
rows = list_visible_entries(db, user, tenant_id, branch_id, include_archived)
|
||||
counts = {"total": len(rows), "due_soon": 0, "overdue": 0}
|
||||
decorated = []
|
||||
for entry, client in rows:
|
||||
state = due_state(entry); counts[state] = counts.get(state, 0) + 1; decorated.append((entry, client, state))
|
||||
return templates.TemplateResponse("modules/credential_vault/templates/credential_vault/dashboard.html", _ctx(request, user, db, rows=decorated, counts=counts, can_manage=can_manage_vault(db, user), include_archived=include_archived))
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
def new_entry(request: Request):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db)
|
||||
if not can_manage_vault(db, user): raise HTTPException(403, "Only firm managers may create credentials.")
|
||||
tenant_id = active_tenant_id(request, user); branch_id = active_branch_id(request, user)
|
||||
clients = db.execute(select(Client).where(Client.tenant_id == tenant_id, Client.is_active.is_(True)).order_by(Client.client_name)).scalars().all()
|
||||
users = db.execute(select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True)).order_by(User.full_name)).scalars().all()
|
||||
registrations = db.execute(select(ClientRegistration).where(ClientRegistration.tenant_id == tenant_id).order_by(ClientRegistration.registration_number)).scalars().all()
|
||||
return templates.TemplateResponse("modules/credential_vault/templates/credential_vault/form.html", _ctx(request, user, db, entry=None, clients=clients, users=users, registrations=registrations, branch_id=branch_id))
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def save_new(request: Request, title: str=Form(...), category: str=Form("government_portal"), client_id: str=Form(""), registration_id: str=Form(""), portal_url: str=Form(""), reference_number: str=Form(""), username: str=Form(""), secret: str=Form(...), additional_secret: str=Form(""), notes: str=Form(""), sensitivity: str=Form("high"), expires_on: str=Form(""), rotation_due_on: str=Form(""), owner_user_id: str=Form(""), allowed_user_ids: list[str]=Form(default=[]), csrf_token: str=Form(...)):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); validate_csrf(request, csrf_token)
|
||||
if not can_manage_vault(db, user): raise HTTPException(403)
|
||||
tenant_id = active_tenant_id(request, user)
|
||||
entry = create_entry(db, tenant_id=tenant_id, branch_id=active_branch_id(request, user), client_id=int(client_id) if client_id else None, registration_id=int(registration_id) if registration_id else None, title=title, category=category, portal_url=portal_url, reference_number=reference_number, username=username, secret=secret, additional_secret=additional_secret, notes=notes, sensitivity=sensitivity, expires_on=_date(expires_on), rotation_due_on=_date(rotation_due_on), owner_user_id=int(owner_user_id) if owner_user_id else user.id, allowed_user_ids_csv=",".join(allowed_user_ids), actor_user_id=user.id)
|
||||
log_access(db, request, user, entry, "create", reason="Credential created"); db.commit()
|
||||
return RedirectResponse(f"/credential-vault/{entry.id}", 303)
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_class=HTMLResponse)
|
||||
def detail(request: Request, entry_id: int):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); entry = db.get(CredentialVaultEntry, entry_id)
|
||||
if not entry or not can_view_entry(db, user, entry, active_branch_id(request, user)): raise HTTPException(404)
|
||||
client = db.get(Client, entry.client_id) if entry.client_id else None
|
||||
versions = db.execute(select(CredentialVaultVersion).where(CredentialVaultVersion.entry_id == entry.id).order_by(CredentialVaultVersion.version_number.desc())).scalars().all()
|
||||
logs = db.execute(select(CredentialVaultAccessLog, User).outerjoin(User, User.id == CredentialVaultAccessLog.actor_user_id).where(CredentialVaultAccessLog.entry_id == entry.id).order_by(CredentialVaultAccessLog.created_at_utc.desc()).limit(100)).all()
|
||||
return templates.TemplateResponse("modules/credential_vault/templates/credential_vault/detail.html", _ctx(request, user, db, entry=entry, client=client, versions=versions, logs=logs, due_state=due_state(entry), can_manage=can_manage_vault(db, user)))
|
||||
|
||||
|
||||
@router.post("/{entry_id}/reveal", response_class=HTMLResponse)
|
||||
async def reveal(request: Request, entry_id: int, current_password: str=Form(...), reason: str=Form(...), csrf_token: str=Form(...)):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); validate_csrf(request, csrf_token); entry = db.get(CredentialVaultEntry, entry_id)
|
||||
if not entry: raise HTTPException(404)
|
||||
values = reveal_entry(db, request, user, entry, current_password, reason)
|
||||
response = templates.TemplateResponse("modules/credential_vault/templates/credential_vault/reveal.html", _ctx(request, user, db, entry=entry, values=values, reason=reason))
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"; response.headers["Pragma"] = "no-cache"; response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{entry_id}/rotate")
|
||||
async def rotate(request: Request, entry_id: int, username: str=Form(""), secret: str=Form(...), additional_secret: str=Form(""), notes: str=Form(""), rotation_due_on: str=Form(""), reason: str=Form(...), csrf_token: str=Form(...)):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); validate_csrf(request, csrf_token)
|
||||
if not can_manage_vault(db, user): raise HTTPException(403)
|
||||
entry = db.get(CredentialVaultEntry, entry_id)
|
||||
if not entry or not can_view_entry(db, user, entry, active_branch_id(request, user)): raise HTTPException(404)
|
||||
rotate_entry(db, entry, username=username, secret=secret, additional_secret=additional_secret, notes=notes, rotation_due_on=_date(rotation_due_on), reason=reason, actor_user_id=user.id)
|
||||
log_access(db, request, user, entry, "rotate", reason=reason); db.commit(); return RedirectResponse(f"/credential-vault/{entry.id}", 303)
|
||||
|
||||
|
||||
@router.post("/{entry_id}/archive")
|
||||
async def archive(request: Request, entry_id: int, reason: str=Form(...), csrf_token: str=Form(...)):
|
||||
with CommonSessionLocal() as db:
|
||||
user = _user(request, db); validate_csrf(request, csrf_token)
|
||||
if not can_manage_vault(db, user): raise HTTPException(403)
|
||||
entry = db.get(CredentialVaultEntry, entry_id)
|
||||
if not entry or not can_view_entry(db, user, entry, active_branch_id(request, user)): raise HTTPException(404)
|
||||
if not reason.strip(): raise HTTPException(400, "Archive reason is required.")
|
||||
entry.status="archived"; entry.archived_at_utc=__import__('datetime').datetime.now(__import__('datetime').timezone.utc); entry.updated_by_user_id=user.id
|
||||
log_access(db, request, user, entry, "archive", reason=reason); db.commit(); return RedirectResponse("/credential-vault", 303)
|
||||
Reference in New Issue
Block a user