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 %} +
Encrypted client and portal credentials. Secret values are never displayed on this page.
| Credential | Client | Category | Rotation/expiry | State | |
|---|---|---|---|---|---|
{{ 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. | |||||
{{ client.client_name if client else 'Firm-level credential' }} ยท {{ entry.category|replace('_',' ')|title }}
Re-authentication and a business reason are mandatory. The reveal is audit logged.
| When | User | Action | Reason | Result |
|---|---|---|---|---|
| {{ 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. | ||||
Do not leave this page unattended. Values disappear when you navigate away.
Audit reason: {{ reason }}