Add phase 5 secure credential vault
This commit is contained in:
@@ -14,6 +14,8 @@ class Settings(BaseSettings):
|
||||
ENV: str = "dev"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-me-to-a-long-random-string"
|
||||
# Separate high-entropy key for tenant-scoped credential encryption. Never rotate without a re-encryption plan.
|
||||
VAULT_MASTER_KEY: str = ""
|
||||
|
||||
COOKIE_SECURE: bool = False
|
||||
COOKIE_SAMESITE: str = "lax"
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.modules.notice_cases.models import NoticeCase, NoticeCaseEvent, NoticeC
|
||||
from app.modules.notifications.automation import start_notification_scheduler
|
||||
from app.modules.registrations.service import seed_registration_types
|
||||
from app.modules.registrations import models as registration_models # noqa: F401
|
||||
from app.modules.credential_vault import models as credential_vault_models # noqa: F401
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
"System Admin",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tenant-scoped encrypted credential vault."""
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.settings import get_settings
|
||||
|
||||
|
||||
class VaultCryptoError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _master_key() -> bytes:
|
||||
settings = get_settings()
|
||||
raw = (getattr(settings, "VAULT_MASTER_KEY", "") or settings.SECRET_KEY or "").strip()
|
||||
if not raw or raw == "change-me-to-a-long-random-string":
|
||||
raise VaultCryptoError("VAULT_MASTER_KEY must be configured with a strong production secret before using the credential vault.")
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def _fernet(tenant_id: int) -> Fernet:
|
||||
digest = hmac.new(_master_key(), f"audit-firm-vault:tenant:{tenant_id}:v1".encode(), hashlib.sha256).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt_value(tenant_id: int, value: str | None) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return _fernet(tenant_id).encrypt(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_value(tenant_id: int, token: str | None) -> str:
|
||||
if not token:
|
||||
return ""
|
||||
try:
|
||||
return _fernet(tenant_id).decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
raise VaultCryptoError("The credential could not be decrypted with the configured vault key.") from exc
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class CredentialVaultEntry(CommonBase):
|
||||
__tablename__ = "credential_vault_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), index=True)
|
||||
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), index=True)
|
||||
registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
category: Mapped[str] = mapped_column(String(60), nullable=False, default="government_portal", index=True)
|
||||
portal_url: Mapped[str | None] = mapped_column(String(800))
|
||||
reference_number: Mapped[str | None] = mapped_column(String(160), index=True)
|
||||
username_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
additional_secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
notes_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
encryption_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
sensitivity: Mapped[str] = mapped_column(String(30), nullable=False, default="high")
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
|
||||
expires_on: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
rotation_due_on: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
allowed_user_ids_csv: Mapped[str | None] = mapped_column(Text)
|
||||
reveal_requires_reason: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
last_rotated_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
archived_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class CredentialVaultVersion(CommonBase):
|
||||
__tablename__ = "credential_vault_versions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
entry_id: Mapped[int] = mapped_column(ForeignKey("credential_vault_entries.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
username_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
secret_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
additional_secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
notes_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
change_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
changed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
changed_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
|
||||
|
||||
|
||||
class CredentialVaultAccessLog(CommonBase):
|
||||
__tablename__ = "credential_vault_access_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), index=True)
|
||||
entry_id: Mapped[int | None] = mapped_column(ForeignKey("credential_vault_entries.id", ondelete="SET NULL"), index=True)
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
fields_accessed_csv: Mapped[str | None] = mapped_column(String(300))
|
||||
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, index=True)
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.passwords import verify_password
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.deps import get_user_roles
|
||||
from app.modules.credential_vault.crypto import decrypt_value, encrypt_value
|
||||
from app.modules.credential_vault.models import CredentialVaultAccessLog, CredentialVaultEntry, CredentialVaultVersion
|
||||
|
||||
MANAGER_ROLES = {"Firm Admin", "Partner", "Branch Manager"}
|
||||
DENIED_ROLES = {"Client", "Consultant"}
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def active_tenant_id(request: Request, user: User) -> int:
|
||||
return int(request.session.get("active_tenant_id") or user.tenant_id)
|
||||
|
||||
|
||||
def active_branch_id(request: Request, user: User) -> int | None:
|
||||
raw = request.session.get("active_branch_id")
|
||||
return int(raw) if raw not in (None, "", 0, "0") else None
|
||||
|
||||
|
||||
def role_names(db: Session, user: User) -> set[str]:
|
||||
return set(get_user_roles(db, user.id))
|
||||
|
||||
|
||||
def can_open_vault(db: Session, user: User) -> bool:
|
||||
roles = role_names(db, user)
|
||||
return bool(roles & MANAGER_ROLES) or "Staff" in roles or "Employee" in roles
|
||||
|
||||
|
||||
def can_manage_vault(db: Session, user: User) -> bool:
|
||||
return bool(role_names(db, user) & MANAGER_ROLES)
|
||||
|
||||
|
||||
def _allowed_ids(entry: CredentialVaultEntry) -> set[int]:
|
||||
result: set[int] = set()
|
||||
for raw in (entry.allowed_user_ids_csv or "").split(","):
|
||||
try:
|
||||
result.add(int(raw.strip()))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def can_view_entry(db: Session, user: User, entry: CredentialVaultEntry, branch_id: int | None = None) -> bool:
|
||||
roles = role_names(db, user)
|
||||
if roles & DENIED_ROLES:
|
||||
return False
|
||||
if entry.tenant_id != user.tenant_id and "System Admin" not in roles:
|
||||
return False
|
||||
if "Firm Admin" in roles or "Partner" in roles:
|
||||
return True
|
||||
if "Branch Manager" in roles:
|
||||
return not entry.branch_id or entry.branch_id == (branch_id or user.branch_id)
|
||||
return entry.owner_user_id == user.id or user.id in _allowed_ids(entry)
|
||||
|
||||
|
||||
def list_visible_entries(db: Session, user: User, tenant_id: int, branch_id: int | None, include_archived: bool = False):
|
||||
q = select(CredentialVaultEntry, Client).outerjoin(Client, Client.id == CredentialVaultEntry.client_id).where(CredentialVaultEntry.tenant_id == tenant_id)
|
||||
if not include_archived:
|
||||
q = q.where(CredentialVaultEntry.status != "archived")
|
||||
if branch_id:
|
||||
q = q.where(or_(CredentialVaultEntry.branch_id.is_(None), CredentialVaultEntry.branch_id == branch_id))
|
||||
rows = db.execute(q.order_by(CredentialVaultEntry.rotation_due_on.asc().nullslast(), CredentialVaultEntry.title)).all()
|
||||
return [(entry, client) for entry, client in rows if can_view_entry(db, user, entry, branch_id)]
|
||||
|
||||
|
||||
def log_access(db: Session, request: Request, user: User, entry: CredentialVaultEntry | None, action: str, *, reason: str | None = None, fields: str | None = None, success: bool = True) -> None:
|
||||
db.add(CredentialVaultAccessLog(
|
||||
tenant_id=entry.tenant_id if entry else user.tenant_id,
|
||||
branch_id=entry.branch_id if entry else user.branch_id,
|
||||
entry_id=entry.id if entry else None,
|
||||
actor_user_id=user.id,
|
||||
action=action,
|
||||
reason=reason,
|
||||
fields_accessed_csv=fields,
|
||||
success=success,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
))
|
||||
|
||||
|
||||
def create_entry(db: Session, *, tenant_id: int, branch_id: int | None, client_id: int | None, registration_id: int | None, title: str, category: str, portal_url: str | None, reference_number: str | None, username: str | None, secret: str, additional_secret: str | None, notes: str | None, sensitivity: str, expires_on: date | None, rotation_due_on: date | None, owner_user_id: int | None, allowed_user_ids_csv: str | None, actor_user_id: int) -> CredentialVaultEntry:
|
||||
if not secret.strip():
|
||||
raise HTTPException(400, "Password, token or secret is required.")
|
||||
entry = CredentialVaultEntry(
|
||||
tenant_id=tenant_id, branch_id=branch_id, client_id=client_id, registration_id=registration_id,
|
||||
title=title.strip(), category=category, portal_url=portal_url or None, reference_number=reference_number or None,
|
||||
username_encrypted=encrypt_value(tenant_id, username), secret_encrypted=encrypt_value(tenant_id, secret) or "",
|
||||
additional_secret_encrypted=encrypt_value(tenant_id, additional_secret), notes_encrypted=encrypt_value(tenant_id, notes),
|
||||
sensitivity=sensitivity, expires_on=expires_on, rotation_due_on=rotation_due_on, owner_user_id=owner_user_id,
|
||||
allowed_user_ids_csv=allowed_user_ids_csv or None, created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
|
||||
)
|
||||
db.add(entry)
|
||||
db.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def reveal_entry(db: Session, request: Request, user: User, entry: CredentialVaultEntry, current_password: str, reason: str) -> dict[str, str]:
|
||||
if not can_view_entry(db, user, entry, active_branch_id(request, user)):
|
||||
log_access(db, request, user, entry, "reveal", reason=reason, success=False); db.commit()
|
||||
raise HTTPException(403, "You are not authorised to reveal this credential.")
|
||||
if not reason.strip():
|
||||
raise HTTPException(400, "A business reason is required.")
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
log_access(db, request, user, entry, "reveal", reason=reason, success=False); db.commit()
|
||||
raise HTTPException(403, "Current ERP password is incorrect.")
|
||||
values = {
|
||||
"username": decrypt_value(entry.tenant_id, entry.username_encrypted),
|
||||
"secret": decrypt_value(entry.tenant_id, entry.secret_encrypted),
|
||||
"additional_secret": decrypt_value(entry.tenant_id, entry.additional_secret_encrypted),
|
||||
"notes": decrypt_value(entry.tenant_id, entry.notes_encrypted),
|
||||
}
|
||||
log_access(db, request, user, entry, "reveal", reason=reason.strip(), fields="username,secret,additional_secret,notes", success=True)
|
||||
db.commit()
|
||||
return values
|
||||
|
||||
|
||||
def rotate_entry(db: Session, entry: CredentialVaultEntry, *, username: str | None, secret: str, additional_secret: str | None, notes: str | None, rotation_due_on: date | None, reason: str, actor_user_id: int) -> None:
|
||||
if not secret.strip() or not reason.strip():
|
||||
raise HTTPException(400, "New secret and rotation reason are required.")
|
||||
version_count = db.execute(select(CredentialVaultVersion).where(CredentialVaultVersion.entry_id == entry.id)).scalars().all()
|
||||
db.add(CredentialVaultVersion(
|
||||
tenant_id=entry.tenant_id, entry_id=entry.id, version_number=len(version_count) + 1,
|
||||
username_encrypted=entry.username_encrypted, secret_encrypted=entry.secret_encrypted,
|
||||
additional_secret_encrypted=entry.additional_secret_encrypted, notes_encrypted=entry.notes_encrypted,
|
||||
change_reason=reason.strip(), changed_by_user_id=actor_user_id,
|
||||
))
|
||||
entry.username_encrypted = encrypt_value(entry.tenant_id, username)
|
||||
entry.secret_encrypted = encrypt_value(entry.tenant_id, secret) or ""
|
||||
entry.additional_secret_encrypted = encrypt_value(entry.tenant_id, additional_secret)
|
||||
entry.notes_encrypted = encrypt_value(entry.tenant_id, notes)
|
||||
entry.rotation_due_on = rotation_due_on
|
||||
entry.last_rotated_at_utc = utcnow()
|
||||
entry.updated_by_user_id = actor_user_id
|
||||
|
||||
|
||||
def due_state(entry: CredentialVaultEntry) -> str:
|
||||
today = date.today()
|
||||
due = entry.rotation_due_on or entry.expires_on
|
||||
if not due:
|
||||
return "not_scheduled"
|
||||
days = (due - today).days
|
||||
if days < 0:
|
||||
return "overdue"
|
||||
if days <= 30:
|
||||
return "due_soon"
|
||||
return "current"
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends "base/layout.html" %}{% block content %}
|
||||
<div class="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h1 class="text-2xl font-bold">Secure Credential Vault</h1><p class="text-sm text-slate-600">Encrypted client and portal credentials. Secret values are never displayed on this page.</p></div>{% if can_manage %}<a href="/credential-vault/new" class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Add credential</a>{% endif %}</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3"><div class="rounded-xl bg-white p-4 shadow"><div class="text-xs text-slate-500">Visible credentials</div><div class="text-2xl font-bold">{{ counts.total }}</div></div><div class="rounded-xl bg-white p-4 shadow"><div class="text-xs text-slate-500">Rotation due soon</div><div class="text-2xl font-bold text-amber-700">{{ counts.due_soon }}</div></div><div class="rounded-xl bg-white p-4 shadow"><div class="text-xs text-slate-500">Overdue</div><div class="text-2xl font-bold text-red-700">{{ counts.overdue }}</div></div></div>
|
||||
<div class="overflow-x-auto rounded-xl bg-white shadow"><table class="min-w-full text-sm"><thead class="bg-slate-50 text-left"><tr><th class="p-3">Credential</th><th class="p-3">Client</th><th class="p-3">Category</th><th class="p-3">Rotation/expiry</th><th class="p-3">State</th><th class="p-3"></th></tr></thead><tbody>{% for entry, client, state in rows %}<tr class="border-t"><td class="p-3"><div class="font-semibold">{{ entry.title }}</div><div class="text-xs text-slate-500">{{ entry.reference_number or 'No reference number' }}</div></td><td class="p-3">{{ client.client_name if client else 'Firm credential' }}</td><td class="p-3">{{ entry.category|replace('_',' ')|title }}</td><td class="p-3">{{ entry.rotation_due_on or entry.expires_on or '-' }}</td><td class="p-3"><span class="rounded-full px-2 py-1 text-xs {% if state=='overdue' %}bg-red-100 text-red-700{% elif state=='due_soon' %}bg-amber-100 text-amber-700{% else %}bg-slate-100 text-slate-700{% endif %}">{{ state|replace('_',' ')|title }}</span></td><td class="p-3"><a class="font-semibold text-brand-700" href="/credential-vault/{{ entry.id }}">Open</a></td></tr>{% else %}<tr><td colspan="6" class="p-8 text-center text-slate-500">No credentials are visible in this workspace.</td></tr>{% endfor %}</tbody></table></div>
|
||||
</div>{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "base/layout.html" %}{% block content %}<div class="mx-auto max-w-5xl space-y-5 p-4 sm:p-6"><div class="flex items-start justify-between"><div><h1 class="text-2xl font-bold">{{ entry.title }}</h1><p class="text-sm text-slate-600">{{ client.client_name if client else 'Firm-level credential' }} · {{ entry.category|replace('_',' ')|title }}</p></div><a href="/credential-vault" class="rounded-lg border px-3 py-2">Back</a></div><div class="grid gap-4 md:grid-cols-2"><section class="rounded-xl bg-white p-5 shadow"><h2 class="font-semibold">Metadata</h2><dl class="mt-3 grid grid-cols-2 gap-3 text-sm"><dt class="text-slate-500">Portal</dt><dd>{% if entry.portal_url %}<a class="text-brand-700" href="{{ entry.portal_url }}" target="_blank" rel="noopener noreferrer">Open portal</a>{% else %}-{% endif %}</dd><dt class="text-slate-500">Reference</dt><dd>{{ entry.reference_number or '-' }}</dd><dt class="text-slate-500">Sensitivity</dt><dd>{{ entry.sensitivity|title }}</dd><dt class="text-slate-500">Rotation due</dt><dd>{{ entry.rotation_due_on or '-' }}</dd><dt class="text-slate-500">Expiry</dt><dd>{{ entry.expires_on or '-' }}</dd><dt class="text-slate-500">Status</dt><dd>{{ entry.status|title }}</dd></dl></section><section class="rounded-xl border border-amber-200 bg-amber-50 p-5"><h2 class="font-semibold">Reveal protected values</h2><p class="mt-1 text-sm text-amber-900">Re-authentication and a business reason are mandatory. The reveal is audit logged.</p><form method="post" action="/credential-vault/{{ entry.id }}/reveal" class="mt-3 space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input required name="reason" placeholder="Business reason" class="w-full rounded-lg border p-2"><input required type="password" name="current_password" autocomplete="current-password" placeholder="Current ERP password" class="w-full rounded-lg border p-2"><button class="rounded-lg bg-slate-900 px-4 py-2 font-semibold text-white">Reveal once</button></form></section></div>{% if can_manage %}<section class="rounded-xl bg-white p-5 shadow"><h2 class="font-semibold">Rotate credential</h2><form method="post" action="/credential-vault/{{ entry.id }}/rotate" class="mt-3 grid gap-3 sm:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="username" placeholder="New username/login ID" class="rounded-lg border p-2"><input required type="password" name="secret" autocomplete="new-password" placeholder="New password/token" class="rounded-lg border p-2"><input type="password" name="additional_secret" autocomplete="new-password" placeholder="New PIN/additional secret" class="rounded-lg border p-2"><input type="date" name="rotation_due_on" class="rounded-lg border p-2"><textarea name="notes" placeholder="Encrypted notes" class="rounded-lg border p-2 sm:col-span-2"></textarea><input required name="reason" placeholder="Rotation reason" class="rounded-lg border p-2 sm:col-span-2"><button class="w-fit rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Rotate and preserve history</button></form></section>{% endif %}<section class="rounded-xl bg-white p-5 shadow"><h2 class="font-semibold">Access audit</h2><div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead><tr><th class="p-2 text-left">When</th><th class="p-2 text-left">User</th><th class="p-2 text-left">Action</th><th class="p-2 text-left">Reason</th><th class="p-2 text-left">Result</th></tr></thead><tbody>{% for log,user_row in logs %}<tr class="border-t"><td class="p-2">{{ log.created_at_utc }}</td><td class="p-2">{{ user_row.full_name or user_row.email if user_row else '-' }}</td><td class="p-2">{{ log.action|title }}</td><td class="p-2">{{ log.reason or '-' }}</td><td class="p-2">{{ 'Success' if log.success else 'Denied' }}</td></tr>{% else %}<tr><td colspan="5" class="p-4 text-slate-500">No access events.</td></tr>{% endfor %}</tbody></table></div></section></div>{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "base/layout.html" %}{% block content %}<div class="mx-auto max-w-4xl p-4 sm:p-6"><h1 class="mb-5 text-2xl font-bold">Add encrypted credential</h1><form method="post" class="grid gap-4 rounded-xl bg-white p-6 shadow sm:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label class="sm:col-span-2">Title<input required name="title" class="mt-1 w-full rounded-lg border p-2"></label><label>Category<select name="category" class="mt-1 w-full rounded-lg border p-2"><option value="government_portal">Government portal</option><option value="banking">Banking</option><option value="email">Email</option><option value="software">Software</option><option value="api_key">API key</option><option value="digital_signature">Digital signature</option><option value="other">Other</option></select></label><label>Sensitivity<select name="sensitivity" class="mt-1 w-full rounded-lg border p-2"><option>high</option><option>critical</option><option>standard</option></select></label><label>Client<select name="client_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Firm-level credential</option>{% for c in clients %}<option value="{{ c.id }}">{{ c.client_name }}</option>{% endfor %}</select></label><label>Registration record<select name="registration_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Not linked</option>{% for r in registrations %}<option value="{{ r.id }}">{{ r.registration_number }}</option>{% endfor %}</select></label><label class="sm:col-span-2">Portal URL<input name="portal_url" type="url" class="mt-1 w-full rounded-lg border p-2"></label><label>Reference number<input name="reference_number" class="mt-1 w-full rounded-lg border p-2"></label><label>Username/login ID<input name="username" autocomplete="off" class="mt-1 w-full rounded-lg border p-2"></label><label>Secret/password/token<input required name="secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label><label>Additional secret/PIN<input name="additional_secret" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border p-2"></label><label>Expires on<input name="expires_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label><label>Rotation due on<input name="rotation_due_on" type="date" class="mt-1 w-full rounded-lg border p-2"></label><label>Owner<select name="owner_user_id" class="mt-1 w-full rounded-lg border p-2"><option value="">Current user</option>{% for u in users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></label><fieldset><legend>Explicit staff access</legend><div class="mt-1 max-h-32 overflow-auto rounded-lg border p-2">{% for u in users %}<label class="block text-sm"><input type="checkbox" name="allowed_user_ids" value="{{ u.id }}"> {{ u.full_name or u.email }}</label>{% endfor %}</div></fieldset><label class="sm:col-span-2">Encrypted notes<textarea name="notes" rows="3" class="mt-1 w-full rounded-lg border p-2"></textarea></label><div class="sm:col-span-2 flex gap-2"><button class="rounded-lg bg-brand-600 px-4 py-2 font-semibold text-white">Save securely</button><a href="/credential-vault" class="rounded-lg border px-4 py-2">Cancel</a></div></form></div>{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "base/layout.html" %}{% block content %}<div class="mx-auto max-w-3xl p-4 sm:p-6"><div class="rounded-xl border border-red-200 bg-white p-6 shadow"><div class="flex items-start justify-between"><div><h1 class="text-xl font-bold">One-time credential reveal</h1><p class="text-sm text-red-700">Do not leave this page unattended. Values disappear when you navigate away.</p></div><a href="/credential-vault/{{ entry.id }}" class="rounded-lg border px-3 py-2">Close reveal</a></div><dl class="mt-5 space-y-4"><div><dt class="text-xs font-semibold uppercase text-slate-500">Username/Login ID</dt><dd class="mt-1 break-all rounded-lg bg-slate-100 p-3 font-mono">{{ values.username or '-' }}</dd></div><div><dt class="text-xs font-semibold uppercase text-slate-500">Password/Token/Secret</dt><dd class="mt-1 break-all rounded-lg bg-slate-900 p-3 font-mono text-white">{{ values.secret }}</dd></div>{% if values.additional_secret %}<div><dt class="text-xs font-semibold uppercase text-slate-500">Additional secret/PIN</dt><dd class="mt-1 break-all rounded-lg bg-slate-100 p-3 font-mono">{{ values.additional_secret }}</dd></div>{% endif %}{% if values.notes %}<div><dt class="text-xs font-semibold uppercase text-slate-500">Encrypted notes</dt><dd class="mt-1 whitespace-pre-wrap rounded-lg bg-slate-100 p-3">{{ values.notes }}</dd></div>{% endif %}</dl><p class="mt-5 text-xs text-slate-500">Audit reason: {{ reason }}</p></div></div>{% endblock %}
|
||||
@@ -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)
|
||||
@@ -34,6 +34,7 @@ from app.modules.aqmm_dashboard.ui import router as aqmm_dashboard_router
|
||||
from app.modules.peer_review_export.ui import router as peer_review_export_router
|
||||
from app.modules.bank_statement_analyzer.ui import router as bank_statement_analyzer_router
|
||||
from app.modules.registrations.ui import router as registrations_ui_router
|
||||
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
||||
|
||||
|
||||
def mount_ui(app: FastAPI) -> None:
|
||||
@@ -66,6 +67,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(work_detail_ui_router)
|
||||
app.include_router(clients_ui_router)
|
||||
app.include_router(registrations_ui_router)
|
||||
app.include_router(credential_vault_ui_router)
|
||||
app.include_router(employees_ui_router)
|
||||
app.include_router(manager_dashboard_router)
|
||||
app.include_router(managers_ui_router)
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
{% if can_manage_clients(current_user, ui_perms, ui_roles) %}<a href="/clients/import" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Import Clients</a>{% endif %}
|
||||
{% if can_export_clients(current_user, ui_perms, ui_roles) %}<a href="/clients/export" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Export Clients</a>{% endif %}
|
||||
{% if can_view_clients(current_user, ui_perms, ui_roles) %}<a href="/registrations" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/registrations') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Registrations & Renewals</a>{% endif %}
|
||||
{% if ("Firm Admin" in ui_roles) or ("Partner" in ui_roles) or ("Branch Manager" in ui_roles) or ("Staff" in ui_roles) or ("Employee" in ui_roles) %}<a href="/credential-vault" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/credential-vault') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Secure Credential Vault</a>{% endif %}
|
||||
{% endif %}
|
||||
{% if has_firm_consultants_menu %}
|
||||
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Consultants</div>
|
||||
|
||||
Reference in New Issue
Block a user