From 1b9f555dcf145a1e60c8c338211e2d2aab551f1a Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 23 Jul 2026 14:44:02 +0530 Subject: [PATCH] Add phase 5 secure credential vault --- alembic/env.py | 1 + ...20260723_phase5_secure_credential_vault.py | 28 +++ app/core/settings.py | 2 + app/core/startup.py | 1 + app/modules/credential_vault/__init__.py | 1 + app/modules/credential_vault/crypto.py | 41 +++++ app/modules/credential_vault/models.py | 77 +++++++++ app/modules/credential_vault/service.py | 159 ++++++++++++++++++ .../templates/credential_vault/dashboard.html | 6 + .../templates/credential_vault/detail.html | 1 + .../templates/credential_vault/form.html | 1 + .../templates/credential_vault/reveal.html | 1 + app/modules/credential_vault/ui.py | 119 +++++++++++++ app/ui/app.py | 2 + app/ui/templates/base/layout.html | 1 + requirements.txt | 1 + 16 files changed, 442 insertions(+) create mode 100644 alembic/versions/20260723_phase5_secure_credential_vault.py create mode 100644 app/modules/credential_vault/__init__.py create mode 100644 app/modules/credential_vault/crypto.py create mode 100644 app/modules/credential_vault/models.py create mode 100644 app/modules/credential_vault/service.py create mode 100644 app/modules/credential_vault/templates/credential_vault/dashboard.html create mode 100644 app/modules/credential_vault/templates/credential_vault/detail.html create mode 100644 app/modules/credential_vault/templates/credential_vault/form.html create mode 100644 app/modules/credential_vault/templates/credential_vault/reveal.html create mode 100644 app/modules/credential_vault/ui.py diff --git a/alembic/env.py b/alembic/env.py index 4e55d5c..e12d48e 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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.notice_cases import models as notice_cases_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 diff --git a/alembic/versions/20260723_phase5_secure_credential_vault.py b/alembic/versions/20260723_phase5_secure_credential_vault.py new file mode 100644 index 0000000..fea1355 --- /dev/null +++ b/alembic/versions/20260723_phase5_secure_credential_vault.py @@ -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") diff --git a/app/core/settings.py b/app/core/settings.py index 58a25f6..776043f 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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" diff --git a/app/core/startup.py b/app/core/startup.py index e8e274a..0757b29 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -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", diff --git a/app/modules/credential_vault/__init__.py b/app/modules/credential_vault/__init__.py new file mode 100644 index 0000000..3c41923 --- /dev/null +++ b/app/modules/credential_vault/__init__.py @@ -0,0 +1 @@ +"""Tenant-scoped encrypted credential vault.""" diff --git a/app/modules/credential_vault/crypto.py b/app/modules/credential_vault/crypto.py new file mode 100644 index 0000000..417e984 --- /dev/null +++ b/app/modules/credential_vault/crypto.py @@ -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 diff --git a/app/modules/credential_vault/models.py b/app/modules/credential_vault/models.py new file mode 100644 index 0000000..3443f3c --- /dev/null +++ b/app/modules/credential_vault/models.py @@ -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) diff --git a/app/modules/credential_vault/service.py b/app/modules/credential_vault/service.py new file mode 100644 index 0000000..d45c9d2 --- /dev/null +++ b/app/modules/credential_vault/service.py @@ -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" diff --git a/app/modules/credential_vault/templates/credential_vault/dashboard.html b/app/modules/credential_vault/templates/credential_vault/dashboard.html new file mode 100644 index 0000000..15c2376 --- /dev/null +++ b/app/modules/credential_vault/templates/credential_vault/dashboard.html @@ -0,0 +1,6 @@ +{% extends "base/layout.html" %}{% block content %} +
+

Secure Credential Vault

Encrypted client and portal credentials. Secret values are never displayed on this page.

{% if can_manage %}Add credential{% endif %}
+
Visible credentials
{{ counts.total }}
Rotation due soon
{{ counts.due_soon }}
Overdue
{{ counts.overdue }}
+
{% for entry, client, state in rows %}{% else %}{% endfor %}
CredentialClientCategoryRotation/expiryState
{{ entry.title }}
{{ entry.reference_number or 'No reference number' }}
{{ client.client_name if client else 'Firm credential' }}{{ entry.category|replace('_',' ')|title }}{{ entry.rotation_due_on or entry.expires_on or '-' }}{{ state|replace('_',' ')|title }}Open
No credentials are visible in this workspace.
+
{% endblock %} diff --git a/app/modules/credential_vault/templates/credential_vault/detail.html b/app/modules/credential_vault/templates/credential_vault/detail.html new file mode 100644 index 0000000..f69490f --- /dev/null +++ b/app/modules/credential_vault/templates/credential_vault/detail.html @@ -0,0 +1 @@ +{% extends "base/layout.html" %}{% block content %}

{{ entry.title }}

{{ client.client_name if client else 'Firm-level credential' }} ยท {{ entry.category|replace('_',' ')|title }}

Back

Metadata

Portal
{% if entry.portal_url %}Open portal{% else %}-{% endif %}
Reference
{{ entry.reference_number or '-' }}
Sensitivity
{{ entry.sensitivity|title }}
Rotation due
{{ entry.rotation_due_on or '-' }}
Expiry
{{ entry.expires_on or '-' }}
Status
{{ entry.status|title }}

Reveal protected values

Re-authentication and a business reason are mandatory. The reveal is audit logged.

{% if can_manage %}

Rotate credential

{% endif %}

Access audit

{% for log,user_row in logs %}{% else %}{% endfor %}
WhenUserActionReasonResult
{{ log.created_at_utc }}{{ user_row.full_name or user_row.email if user_row else '-' }}{{ log.action|title }}{{ log.reason or '-' }}{{ 'Success' if log.success else 'Denied' }}
No access events.
{% endblock %} diff --git a/app/modules/credential_vault/templates/credential_vault/form.html b/app/modules/credential_vault/templates/credential_vault/form.html new file mode 100644 index 0000000..c1f2e42 --- /dev/null +++ b/app/modules/credential_vault/templates/credential_vault/form.html @@ -0,0 +1 @@ +{% extends "base/layout.html" %}{% block content %}

Add encrypted credential

Explicit staff access
{% for u in users %}{% endfor %}
Cancel
{% endblock %} diff --git a/app/modules/credential_vault/templates/credential_vault/reveal.html b/app/modules/credential_vault/templates/credential_vault/reveal.html new file mode 100644 index 0000000..be54e63 --- /dev/null +++ b/app/modules/credential_vault/templates/credential_vault/reveal.html @@ -0,0 +1 @@ +{% extends "base/layout.html" %}{% block content %}

One-time credential reveal

Do not leave this page unattended. Values disappear when you navigate away.

Close reveal
Username/Login ID
{{ values.username or '-' }}
Password/Token/Secret
{{ values.secret }}
{% if values.additional_secret %}
Additional secret/PIN
{{ values.additional_secret }}
{% endif %}{% if values.notes %}
Encrypted notes
{{ values.notes }}
{% endif %}

Audit reason: {{ reason }}

{% endblock %} diff --git a/app/modules/credential_vault/ui.py b/app/modules/credential_vault/ui.py new file mode 100644 index 0000000..e1fac3b --- /dev/null +++ b/app/modules/credential_vault/ui.py @@ -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) diff --git a/app/ui/app.py b/app/ui/app.py index d3ecaf9..ee03627 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -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) diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html index 22efecf..85dc337 100644 --- a/app/ui/templates/base/layout.html +++ b/app/ui/templates/base/layout.html @@ -376,6 +376,7 @@ {% if can_manage_clients(current_user, ui_perms, ui_roles) %}Import Clients{% endif %} {% if can_export_clients(current_user, ui_perms, ui_roles) %}Export Clients{% endif %} {% if can_view_clients(current_user, ui_perms, ui_roles) %}Registrations & Renewals{% 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) %}Secure Credential Vault{% endif %} {% endif %} {% if has_firm_consultants_menu %}
Consultants
diff --git a/requirements.txt b/requirements.txt index 8c8b244..e931c6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ itsdangerous pandas xlsxwriter pdfplumber +cryptography==46.0.4