Add phase 5 secure credential vault

This commit is contained in:
A R R R Associates
2026-07-23 14:44:02 +05:30
parent d113db0d20
commit 1b9f555dcf
16 changed files with 442 additions and 0 deletions
+1
View File
@@ -46,6 +46,7 @@ from app.modules.email_integration import models as email_integration_models #
from app.modules.domain_management import models as domain_management_models # noqa: F401 from app.modules.domain_management import models as domain_management_models # noqa: F401
from app.modules.notice_cases import models as notice_cases_models # noqa: F401 from app.modules.notice_cases import models as notice_cases_models # noqa: F401
from app.modules.registrations import models as registration_models # noqa: F401 from app.modules.registrations import models as registration_models # noqa: F401
from app.modules.credential_vault import models as credential_vault_models # noqa: F401
config = context.config config = context.config
@@ -0,0 +1,28 @@
"""Phase 5 secure credential vault.
Revision ID: 20260723_phase5_secure_credential_vault
Revises: 20260723_phase4_registration_lifecycle
"""
from alembic import op
import sqlalchemy as sa
revision = "20260723_phase5_secure_credential_vault"
down_revision = "20260723_phase4_registration_lifecycle"
branch_labels = None
depends_on = None
def upgrade():
op.create_table("credential_vault_entries",
sa.Column("id", sa.Integer(), primary_key=True), sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL")), sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE")), sa.Column("registration_id", sa.Integer(), sa.ForeignKey("client_registrations.id", ondelete="SET NULL")),
sa.Column("title", sa.String(200), nullable=False), sa.Column("category", sa.String(60), nullable=False, server_default="government_portal"), sa.Column("portal_url", sa.String(800)), sa.Column("reference_number", sa.String(160)), sa.Column("username_encrypted", sa.Text()), sa.Column("secret_encrypted", sa.Text(), nullable=False), sa.Column("additional_secret_encrypted", sa.Text()), sa.Column("notes_encrypted", sa.Text()), sa.Column("encryption_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("sensitivity", sa.String(30), nullable=False, server_default="high"), sa.Column("status", sa.String(30), nullable=False, server_default="active"), sa.Column("expires_on", sa.Date()), sa.Column("rotation_due_on", sa.Date()), sa.Column("owner_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("allowed_user_ids_csv", sa.Text()), sa.Column("reveal_requires_reason", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("last_rotated_at_utc", sa.DateTime(timezone=True)), sa.Column("archived_at_utc", sa.DateTime(timezone=True)), sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False))
for col in ["tenant_id","branch_id","client_id","registration_id","title","category","reference_number","status","expires_on","rotation_due_on","owner_user_id"]: op.create_index(f"ix_credential_vault_entries_{col}", "credential_vault_entries", [col])
op.create_table("credential_vault_versions", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), sa.Column("entry_id", sa.Integer(), sa.ForeignKey("credential_vault_entries.id", ondelete="CASCADE"), nullable=False), sa.Column("version_number", sa.Integer(), nullable=False), sa.Column("username_encrypted", sa.Text()), sa.Column("secret_encrypted", sa.Text(), nullable=False), sa.Column("additional_secret_encrypted", sa.Text()), sa.Column("notes_encrypted", sa.Text()), sa.Column("change_reason", sa.Text(), nullable=False), sa.Column("changed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("changed_at_utc", sa.DateTime(timezone=True), nullable=False))
op.create_index("ix_credential_vault_versions_tenant_id", "credential_vault_versions", ["tenant_id"]); op.create_index("ix_credential_vault_versions_entry_id", "credential_vault_versions", ["entry_id"])
op.create_table("credential_vault_access_logs", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL")), sa.Column("entry_id", sa.Integer(), sa.ForeignKey("credential_vault_entries.id", ondelete="SET NULL")), sa.Column("actor_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("action", sa.String(40), nullable=False), sa.Column("reason", sa.Text()), sa.Column("fields_accessed_csv", sa.String(300)), sa.Column("success", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("ip_address", sa.String(100)), sa.Column("user_agent", sa.String(500)), sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False))
for col in ["tenant_id","branch_id","entry_id","actor_user_id","action","created_at_utc"]: op.create_index(f"ix_credential_vault_access_logs_{col}", "credential_vault_access_logs", [col])
def downgrade():
op.drop_table("credential_vault_access_logs"); op.drop_table("credential_vault_versions"); op.drop_table("credential_vault_entries")
+2
View File
@@ -14,6 +14,8 @@ class Settings(BaseSettings):
ENV: str = "dev" ENV: str = "dev"
DEBUG: bool = True DEBUG: bool = True
SECRET_KEY: str = "change-me-to-a-long-random-string" 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_SECURE: bool = False
COOKIE_SAMESITE: str = "lax" COOKIE_SAMESITE: str = "lax"
+1
View File
@@ -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.notifications.automation import start_notification_scheduler
from app.modules.registrations.service import seed_registration_types from app.modules.registrations.service import seed_registration_types
from app.modules.registrations import models as registration_models # noqa: F401 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 = [ DEFAULT_ROLES = [
"System Admin", "System Admin",
+1
View File
@@ -0,0 +1 @@
"""Tenant-scoped encrypted credential vault."""
+41
View File
@@ -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
+77
View File
@@ -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)
+159
View File
@@ -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 %}
+119
View File
@@ -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)
+2
View File
@@ -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.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.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.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: 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(work_detail_ui_router)
app.include_router(clients_ui_router) app.include_router(clients_ui_router)
app.include_router(registrations_ui_router) app.include_router(registrations_ui_router)
app.include_router(credential_vault_ui_router)
app.include_router(employees_ui_router) app.include_router(employees_ui_router)
app.include_router(manager_dashboard_router) app.include_router(manager_dashboard_router)
app.include_router(managers_ui_router) app.include_router(managers_ui_router)
+1
View File
@@ -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_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_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 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 %} {% endif %}
{% if has_firm_consultants_menu %} {% if has_firm_consultants_menu %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Consultants</div> <div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Consultants</div>
+1
View File
@@ -18,3 +18,4 @@ itsdangerous
pandas pandas
xlsxwriter xlsxwriter
pdfplumber pdfplumber
cryptography==46.0.4