Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/app/alembic/versions/076_reencrypt_credentials_envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""re-encrypt stored credentials to the kms.v2 envelope format

Revision ID: 076
Revises: 075
Create Date: 2026-08-07

One-shot data backfill onto envelope encryption (KMS-wrapped data key + AES-GCM).
Empty table (fresh DB) and Fernet-only (KMS inactive) environments are no-ops, so
replaying is safe. Requires KMS/IAM access in the migrate runtime — the migrate
container needs the same role/creds the app uses, else the upgrade aborts.
"""
from alembic import op
from sqlmodel import Session

from app.services.credentials.reencrypt import (
execute_credential_reencrypt,
execute_credential_reencrypt_fernet,
)

# revision identifiers, used by Alembic.
revision = "076"
down_revision = "075"
branch_labels = None
depends_on = None


def upgrade():
bind = op.get_bind()
with Session(bind=bind) as session:
execute_credential_reencrypt(session=session)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kartpop This will invoke the sync function and execute execute_credential_reencrypt

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are only 497 credential rows in prod db. So sync option should be ok.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdw should we do that inside a migration or a separate private endpoint @vprashrex

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we discussed this before. Invoking it via an endpoint isn’t the right approach because it should be idempotent and run only once. A migration is the better choice since it guarantees a one-time execution and keeps the change consistent across environments.



def downgrade():
# Reverse the envelope backfill: convert kms.v1/kms.v2 rows back to legacy Fernet.
bind = op.get_bind()
with Session(bind=bind) as session:
execute_credential_reencrypt_fernet(session=session)
Comment on lines +33 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline backend/app/alembic/versions/076_reencrypt_credentials_envelope.py --items all --type function
rg -n '^def (upgrade|downgrade)\(' backend/app/alembic/versions/076_reencrypt_credentials_envelope.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 314


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,90c' <<'EOF'
import os
from pathlib import Path
from typing import Any

from pydantic import BaseModel, ValidationError

class SimpleConfig(BaseModel):
    value: str

    def validate_value(self) -> str:
        if not self.value:
            raise ValidationError("value must exist")
        return self.value

def process_config(config: SimpleConfig) -> str:
    return config.validate_value()

if __name__ == "__main__":
    config = SimpleConfig(value="example")
    print(process_config(config))
EOF

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,90p' backend/app/alembic/versions/076_reencrypt_credentials_envelope.py

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("backend/app/alembic/versions/076_reencrypt_credentials_envelope.py")
tree = ast.parse(path.read_text(), filename=str(path))
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef) and node.name in ("upgrade", "downgrade"):
        print(node.name, "returns_annotation=", ast.unparse(node.returns) if node.returns else None)
PY

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 1346


Add return annotations to upgrade and downgrade.

Both migration functions return None, so declare -> None on each function definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/alembic/versions/076_reencrypt_credentials_envelope.py` around
lines 33 - 37, Add explicit -> None return annotations to both migration
functions, upgrade and downgrade, while leaving their existing migration logic
unchanged.

Source: Coding guidelines

53 changes: 46 additions & 7 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from botocore.client import BaseClient
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from jwt.exceptions import InvalidTokenError
from passlib.context import CryptContext
Expand All @@ -41,6 +42,8 @@

# Marks KMS-encrypted credentials; rows without it are legacy Fernet.
KMS_CIPHERTEXT_PREFIX = "kms.v1:"
# Envelope-encrypted credentials: KMS-wrapped data key + AES-256-GCM payload.
KMS_ENVELOPE_PREFIX = "kms.v2:"

_kms_client: BaseClient | None = None

Expand Down Expand Up @@ -196,32 +199,68 @@ def get_password_hash(password: str) -> str:


def encrypt_credentials(credentials: dict[str, Any]) -> str:
"""Encrypt credentials for storage. KMS outside dev, Fernet otherwise.
"""Encrypt credentials for storage. KMS envelope outside dev, Fernet otherwise.

KMS Encrypt caps plaintext at 4096 bytes, so payloads must stay under that.
Envelope format: `kms.v2:<wrapped_dek>:<nonce>:<ciphertext>`, each segment
base64. The DEK is generated by KMS, used locally for AES-256-GCM, and only
its KMS-wrapped form is persisted; the plaintext DEK never leaves this call.

NOTE: THIS FUNCTION SHOULD ONLY BE USED FOR ENCRYPTING TO KMS ENVELOPE.
AND SHOULD NOT BE USED FOR ANY OTHER ENCRYPTION OR ROTATION METHOD.
"""
try:
credentials_str = json.dumps(credentials)
if _use_kms():
response = get_kms_client().encrypt(
resp = get_kms_client().generate_data_key(
KeyId=settings.AWS_KMS_KEY_ID,
Plaintext=credentials_str.encode(),
KeySpec="AES_256",
)
dek = resp["Plaintext"]
wrapped_dek = resp["CiphertextBlob"]
nonce = os.urandom(12)
ct = AESGCM(dek).encrypt(nonce, credentials_str.encode(), None)
return (
f"{KMS_ENVELOPE_PREFIX}"
f"{base64.b64encode(wrapped_dek).decode()}:"
f"{base64.b64encode(nonce).decode()}:"
f"{base64.b64encode(ct).decode()}"
)
encoded = base64.b64encode(response["CiphertextBlob"]).decode()
return f"{KMS_CIPHERTEXT_PREFIX}{encoded}"
return get_fernet().encrypt(credentials_str.encode()).decode()
except Exception as e:
# Log the real cause (may carry AWS ARNs); never surface it to callers.
logger.error(f"[encrypt_credentials] Encryption failed | error: {e}")
raise ValueError("Failed to encrypt credentials")


def encrypt_fernet(credentials: dict[str, Any]) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline backend/app/core/security.py --items all --type function --match 'encrypt_credentials|encrypt_fernet|decrypt_credentials'
rg -n -C 2 'TypeAlias|JSONValue|JsonValue|dict\[str, Any\]' backend/app

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 50388


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== security.py definitions =="
sed -n '180,260p' backend/app/core/security.py

echo
echo "== imports and Any usage in core/security.py =="
sed -n '1,40p' backend/app/core/security.py
rg -n '^(import|from|def |@)' backend/app/core/security.py

echo
echo "== search reusable aliases excluding any backend/app =="
rg -n -C 1 'JSON(Value|Data)|Json(Value|Data)|Union\[.*None|dict\[str, .*\]\]' "$PWD" --glob '!backend/app/**' --glob '!**/__pycache__/**' | head -160

echo
echo "== check if any JsonValue/JSONValue file exists =="
git ls-files | rg '(^|/).*json(_value|_data|value|data).*\.py$|JsonValue|JSONValue' || true

echo
echo "== deterministic json.dumps behavior for dict[str, Any] values =="
python3 - <<'PY'
import json
from typing import Any

values = [None, True, 0, "", [], {}, object(), lambda x: x]
for v in values:
    try:
        json.dumps({"credentials": {"key": v}})
    except Exception as exc:
        print(type(v).__name__, "rejected:", type(exc).__name__, str(exc))
    else:
        print(type(v).__name__, "serialized")
PY

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 5998


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== JSONValue aliases in tracked files =="
rg -n 'JSON(Value|Data)|Json(Value|Data)|JSONValue|JsonValue|Literal\[(true|null|false)|TypeAlias' --glob '*.py' . | head -200

echo
echo "== deterministic json.dumps behavior for narrow Any values =="
python3 - <<'PY'
import json
from typing import Any, Literal

values = [None, True, 0, "", [], {}, object(), lambda x: x]
for v in values:
    try:
        json.dumps({"credentials": {"key": v}})
    except Exception as exc:
        print(type(v).__name__, "rejected:", type(exc).__name__, str(exc))
    else:
        print(type(v).__name__, "serialized")

for raw in ["true", "null", "false"]:
    try:
        json.dumps(raw, indent=2)
    except Exception as exc:
        print(raw, "rejected:", type(exc).__name__, str(exc))
    else:
        print(repr(raw), "serialized")
PY

echo
echo "== security decryption return type context =="
sed -n '240,290p' backend/app/core/security.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 210


Use a narrow JSON value type for credentials.

dict[str, Any] accepts values such as callables or custom objects that json.dumps rejects at runtime before encryption. Define a recursive JSON value alias and apply it to both credential encryption and decryption signatures so all non-serializable values fail earlier in the crypto contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/core/security.py` at line 235, Define a recursive JSON value type
alias in security.py and replace dict[str, Any] with the corresponding
JSON-object type in both encrypt_fernet and decrypt_fernet signatures. Ensure
the annotations permit only JSON-serializable primitive, array, and object
values so invalid credentials are rejected at the crypto contract boundary.

Source: Coding guidelines

"""Encrypt credentials with Fernet regardless of the active mode.

Forces the local Fernet path even when _use_kms() is true; used to downgrade
KMS rows back to Fernet. Output decrypts only under the current SECRET_KEY.
"""
try:
return get_fernet().encrypt(json.dumps(credentials).encode()).decode()
except Exception as e:
logger.error(f"[encrypt_fernet] Encryption failed | error: {e}")
raise ValueError("Failed to encrypt credentials")


def decrypt_credentials(encrypted_credentials: str) -> dict[str, Any]:
"""Decrypt stored credentials. Routing is by ciphertext prefix, not the
active mode, so legacy Fernet rows always decrypt even after KMS cutover.
"""
try:
if encrypted_credentials.startswith(KMS_CIPHERTEXT_PREFIX):
if encrypted_credentials.startswith(KMS_ENVELOPE_PREFIX):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if-elif condition checks the format of the existing credential and then decrypts it using the corresponding decryption logic for that format.

payload = encrypted_credentials[len(KMS_ENVELOPE_PREFIX) :]
# Exactly 3 segments; a wrong count raises ValueError caught below.
wrapped_b64, nonce_b64, ct_b64 = payload.split(":")
wrapped_dek = base64.b64decode(wrapped_b64)
nonce = base64.b64decode(nonce_b64)
ct = base64.b64decode(ct_b64)
# KMS infers the key from the wrapped blob; do not pass KeyId.
dek = get_kms_client().decrypt(CiphertextBlob=wrapped_dek)["Plaintext"]
decrypted_str = AESGCM(dek).decrypt(nonce, ct, None).decode()
elif encrypted_credentials.startswith(KMS_CIPHERTEXT_PREFIX):
blob = base64.b64decode(encrypted_credentials[len(KMS_CIPHERTEXT_PREFIX) :])
response = get_kms_client().decrypt(CiphertextBlob=blob)
decrypted_str = response["Plaintext"].decode()
Expand Down
76 changes: 75 additions & 1 deletion backend/app/services/credentials/reencrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@

from app.core.config import settings
from app.core.db import engine
from app.core.security import _use_kms, decrypt_credentials, encrypt_credentials
from app.core.security import (
KMS_CIPHERTEXT_PREFIX,
KMS_ENVELOPE_PREFIX,
_use_kms,
decrypt_credentials,
encrypt_credentials,
encrypt_fernet,
)
from app.core.util import now
from app.crud.credentials import list_all_credentials

Expand All @@ -24,7 +31,21 @@ def execute_credential_reencrypt(*, session: Session | None = None) -> dict[str,
return _reencrypt(owned_session)


def execute_credential_reencrypt_fernet(
*, session: Session | None = None
) -> dict[str, int]:
if session is not None:
return _reencrypt_fernet(session)
with Session(engine) as owned_session:
return _reencrypt_fernet(owned_session)


def _reencrypt(session: Session) -> dict[str, int]:
"""
DO NOT USE MIGRATION BEYOND 076
THIS IS ONLY MEANT TO IMPLEMENT KMS ENVELOPE MIGRATION,
AND SHOULD NOT BE USED FOR ROTATION OF KMS CMK.
"""
if not _use_kms():
logger.info(
f"[execute_credential_reencrypt] Skipped, KMS inactive | "
Expand All @@ -39,6 +60,9 @@ def _reencrypt(session: Session) -> dict[str, int]:
converted = 0
try:
for row in rows:
if row.credential.startswith(KMS_ENVELOPE_PREFIX):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, will this mess up roll back by any chance? should we make in place changes to kms.v1 migration?

also later on if we have kms.v3 or something (or if we choose to rotate the KMS CMK), then we have to be careful not to re-use this function and instead write a new one - otherwise it will silently skip the entries we would want it to reencrypt

should we instead have two different versions of the _reencrypt function? there would be some code duplication, but will probably keep things cleaner - less chances of error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like we did for base64 to S3 uri for TTS and STT payload, we had added a batchwise checkpointing (roll back txn if error occurs per 20 rows, stop further execution, restart). This enabled the system to have a limited blast radius.

# Already in the new envelope format; skip.
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
plaintext = decrypt_credentials(row.credential)
new_ciphertext = encrypt_credentials(plaintext)
if decrypt_credentials(new_ciphertext) != plaintext:
Expand All @@ -63,3 +87,53 @@ def _reencrypt(session: Session) -> dict[str, int]:
f"[execute_credential_reencrypt] Done | total: {total}, converted: {converted}"
)
return {"total": total, "converted": converted}


def _reencrypt_fernet(session: Session) -> dict[str, int]:
"""Downgrade KMS credentials (kms.v1/kms.v2) back to legacy Fernet — nothing else.

Only KMS rows are converted; Fernet rows are left untouched. Needs KMS access to
decrypt the existing rows; output decrypts only under the current SECRET_KEY.
One-shot, atomic — any failure rolls back and re-raises.
"""
if not _use_kms():
logger.info(
f"[_reencrypt_fernet] Skipped, KMS inactive | "
f"environment: {settings.ENVIRONMENT}"
)
return {"total": 0, "converted": 0}

rows = list_all_credentials(session=session)
total = len(rows)
logger.info(f"[_reencrypt_fernet] Starting | total: {total}")

converted = 0
try:
for row in rows:
# Convert only KMS rows; anything without a KMS prefix is already Fernet.
if not row.credential.startswith(
(KMS_ENVELOPE_PREFIX, KMS_CIPHERTEXT_PREFIX)
):
continue
plaintext = decrypt_credentials(row.credential)
new_ciphertext = encrypt_fernet(plaintext)
if decrypt_credentials(new_ciphertext) != plaintext:
raise ValueError(f"roundtrip mismatch for credential id {row.id}")

row.credential = new_ciphertext
row.updated_at = now()
session.add(row)
converted += 1

session.commit()
except Exception as e:
session.rollback()
logger.error(
f"[_reencrypt_fernet] Failed, rolled back all rows | "
f"converted-before-abort: {converted}/{total}, error: {e}",
exc_info=True,
)
raise

logger.info(f"[_reencrypt_fernet] Done | total: {total}, converted: {converted}")
return {"total": total, "converted": converted}
63 changes: 62 additions & 1 deletion backend/app/tests/core/test_security.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import base64
import json
from datetime import timedelta

import boto3
Expand All @@ -11,6 +13,7 @@
from app.core.security import (
ALGORITHM,
KMS_CIPHERTEXT_PREFIX,
KMS_ENVELOPE_PREFIX,
APIKeyManager,
create_access_token,
create_refresh_token,
Expand Down Expand Up @@ -65,7 +68,7 @@ def test_kms_roundtrip(self, kms_key):

encrypted = encrypt_credentials(creds)

assert encrypted.startswith(KMS_CIPHERTEXT_PREFIX)
assert encrypted.startswith(KMS_ENVELOPE_PREFIX)
assert decrypt_credentials(encrypted) == creds

def test_dual_read_fernet_row_with_kms_active(self, monkeypatch, kms_key):
Expand All @@ -78,6 +81,64 @@ def test_dual_read_fernet_row_with_kms_active(self, monkeypatch, kms_key):
monkeypatch.setattr(settings, "ENVIRONMENT", "staging")
assert decrypt_credentials(fernet_encrypted) == creds

def test_kms_v2_envelope_roundtrip(self, kms_key):
creds = {"openai": {"api_key": "sk-envelope-123"}}

encrypted = encrypt_credentials(creds)

assert encrypted.startswith(KMS_ENVELOPE_PREFIX)
segments = encrypted[len(KMS_ENVELOPE_PREFIX) :].split(":")
assert len(segments) == 3
for seg in segments:
base64.b64decode(seg) # each segment must be valid base64
assert decrypt_credentials(encrypted) == creds

def test_kms_v2_large_payload_over_4096_bytes(self, kms_key):
# Direct KMS encrypt caps at 4096 bytes; envelope encryption has no such limit.
creds = {
"service_account": {"private_key": "k" * 5000, "client_email": "svc@x"}
}
assert len(json.dumps(creds).encode()) > 4096

encrypted = encrypt_credentials(creds)

assert encrypted.startswith(KMS_ENVELOPE_PREFIX)
assert decrypt_credentials(encrypted) == creds

def test_v1_row_still_decrypts_with_v2_active(self, kms_key):
creds = {"api_key": "sk-v1-legacy"}
blob = security._kms_client.encrypt(
KeyId=kms_key, Plaintext=json.dumps(creds).encode()
)["CiphertextBlob"]
v1_ciphertext = KMS_CIPHERTEXT_PREFIX + base64.b64encode(blob).decode()

assert decrypt_credentials(v1_ciphertext) == creds

def test_new_writes_produce_v2(self, kms_key):
encrypted = encrypt_credentials({"api_key": "sk-new"})

assert encrypted.startswith(KMS_ENVELOPE_PREFIX)
assert not encrypted.startswith(KMS_CIPHERTEXT_PREFIX)

def test_v2_tampered_ciphertext_raises(self, kms_key):
encrypted = encrypt_credentials({"api_key": "sk-tamper"})
wrapped_b64, nonce_b64, ct_b64 = encrypted[len(KMS_ENVELOPE_PREFIX) :].split(
":"
)
ct = bytearray(base64.b64decode(ct_b64))
ct[0] ^= 0xFF
tampered = (
f"{KMS_ENVELOPE_PREFIX}{wrapped_b64}:{nonce_b64}:"
f"{base64.b64encode(bytes(ct)).decode()}"
)

with pytest.raises(ValueError, match="Failed to decrypt credentials"):
decrypt_credentials(tampered)

def test_v2_wrong_segment_count_raises(self, kms_key):
with pytest.raises(ValueError, match="Failed to decrypt credentials"):
decrypt_credentials(f"{KMS_ENVELOPE_PREFIX}onlyoneseg")
Comment on lines +84 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
git ls-files | rg 'backend/app/(tests/core/test_security.py|alembic/versions/076_reencrypt_credentials_envelope.py|tests/services/credentials/test_reencrypt.py)$' || true

echo
echo "== test_security.py relevant section =="
if [ -f backend/app/tests/core/test_security.py ]; then
  nl -ba backend/app/tests/core/test_security.py | sed -n '75,145p'
fi

echo
echo "== alembic migration relevant section =="
if [ -f backend/app/alembic/versions/076_reencrypt_credentials_envelope.py ]; then
  nl -ba backend/app/alembic/versions/076_reencrypt_credentials_envelope.py | sed -n '1,60p'
fi

echo
echo "== test_reencrypt.py relevant section =="
if [ -f backend/app/tests/services/credentials/test_reencrypt.py ]; then
  nl -ba backend/app/tests/services/credentials/test_reencrypt.py | sed -n '30,55p'
fi

echo
echo "== Existing typing fixtures in tests/security/alembic to infer exact fixture names =="
rg -n 'def (kms_key|monkeypatch)|class|Session|encrypt_credentials|decrypt_credentials|KMS_ENVELOPE_PREFIX' backend/app/tests/core/test_security.py backend/app/alembic/versions/076_reencrypt_credentials_envelope.py backend/app/tests/services/credentials/test_reencrypt.py | sed -n '1,120p'

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
git ls-files | rg 'backend/app/(tests/core/test_security.py|alembic/versions/076_reencrypt_credentials_envelope.py|tests/services/credentials/test_reencrypt.py)$' || true

echo
echo "== test_security.py relevant section =="
if [ -f backend/app/tests/core/test_security.py ]; then
  nl -ba backend/app/tests/core/test_security.py | sed -n '75,145p'
fi

echo
echo "== alembic migration relevant section =="
if [ -f backend/app/alembic/versions/076_reencrypt_credentials_envelope.py ]; then
  nl -ba backend/app/alembic/versions/076_reencrypt_credentials_envelope.py | sed -n '1,60p'
fi

echo
echo "== test_reencrypt.py relevant section =="
if [ -f backend/app/tests/services/credentials/test_reencrypt.py ]; then
  nl -ba backend/app/tests/services/credentials/test_reencrypt.py | sed -n '30,55p'
fi

echo
 echo "== Existing typing fixtures in tests/security/alembic to infer exact fixture names =="
rg -n 'def (kms_key|monkeypatch)|class|Session|encrypt_credentials|decrypt_credentials|KMS_ENVELOPE_PREFIX' backend/app/tests/core/test_security.py backend/app/alembic/versions/076_reencrypt_credentials_envelope.py backend/app/tests/services/credentials/test_reencrypt.py | sed -n '1,120p'

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 437


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

files = [
    Path("backend/app/tests/core/test_security.py"),
    Path("backend/app/alembic/versions/076_reencrypt_credentials_envelope.py"),
    Path("backend/app/tests/services/credentials/test_reencrypt.py"),
]

for path in files:
    print(f"\n== {path} lines 1-180 or relevant ==")
    lines = path.read_text(encoding="utf-8").splitlines()
    start, end = max(1, min(180, len(lines))), len(lines)
    if path.name == "backend/app/tests/core/test_security.py":
        start, end = max(1, 75), min(145, len(lines))
    if path.name == "backend/app/alembic/versions/076_reencrypt_credentials_envelope.py":
        start, end = max(1, 1), min(60, len(lines))
    if path.name == "backend/app/tests/services/credentials/test_reencrypt.py":
        start, end = max(1, 30), min(55, len(lines))
    for i in range(start, end + 1):
        print(f"{i:5}: {lines[i-1]}")
PY

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 9435


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [
    Path("backend/app/tests/core/test_security.py"),
    Path("backend/app/alembic/versions/076_reencrypt_credentials_envelope.py"),
    Path("backend/app/tests/services/credentials/test_reencrypt.py"),
]:
    print(f"\n== {path} lines 1-120 ==")
    lines = path.read_text(encoding="utf-8").splitlines()
    start, end = max(1, min(120, len(lines))), len(lines)
    for i in range(start, end + 1):
        print(f"{i:5}: {lines[i-1]}")
PY

echo
echo "== Read-only annotation extractor for target lines =="
python3 - <<'PY'
from pathlib import Path
import re

files = [
    Path("backend/app/tests/core/test_security.py"),
    Path("backend/app/alembic/versions/076_reencrypt_credentials_envelope.py"),
    Path("backend/app/tests/services/credentials/test_reencrypt.py"),
]
def target_lines(path: Path):
    text = path.read_text()
    all_lines = text.splitlines()
    matches = list(re.finditer(r'(def|async def)\s+([A-Za-z_]\w*)', text))
    results = []
    for m in matches:
        lineno = text.count("\n", 0, m.start()) + 1
        if (
            path.name == "backend/app/tests/core/test_security.py" and lineno in range(84, 140 + 1)
        ) or (
            path.name == "backend/app/alembic/versions/076_reencrypt_credentials_envelope.py" and lineno in range(25, 33 + 1)
        ) or (
            path.name == "backend/app/tests/services/credentials/test_reencrypt.py" and lineno == 43
        ):
            line = all_lines[lineno - 1]
            has_self = bool(re.search(r'\bself\b', line))
            has_self_annot = bool(re.search(r'\bself\s*:\s*[^=,\n:]+', line))
            has_return_arrow = " -> " in line
            results.append((lineno, line, has_self, has_self_annot, has_return_arrow))
    return results

for path in files:
    print(f"{path}")
    for item in target_lines(path):
        print(f"  line {item[0]} self_annot={item[3]} return_annot={item[4]}")
        print(f"    {item[1]}")
PY

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 12407


Add narrow type hints to the new KMS credential tests.

The test method signatures in backend/app/tests/core/test_security.py lines 84-140 still omit concrete annotations. Add each fixture parameter type and -> None; apply the same rule to the new test method at backend/app/tests/services/credentials/test_reencrypt.py#43.

🧰 Tools
🪛 ast-grep (0.45.0)

[info] 100-100: use jsonify instead of json.dumps for JSON output
Context: json.dumps(creds)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 110-110: use jsonify instead of json.dumps for JSON output
Context: json.dumps(creds)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

📍 Affects 3 files
  • backend/app/tests/core/test_security.py#L84-L140 (this comment)
  • backend/app/alembic/versions/076_reencrypt_credentials_envelope.py#L25-L33
  • backend/app/tests/services/credentials/test_reencrypt.py#L43-L43
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/tests/core/test_security.py` around lines 84 - 140, Update the
new test methods in backend/app/tests/core/test_security.py:84-140 and the test
at backend/app/tests/services/credentials/test_reencrypt.py:43 to annotate each
fixture parameter with its concrete fixture type and add -> None return
annotations. The site
backend/app/alembic/versions/076_reencrypt_credentials_envelope.py:25-33
requires no direct change.

Source: Coding guidelines



class TestAPIKeyManager:
"""Test suite for APIKeyManager class."""
Expand Down
41 changes: 39 additions & 2 deletions backend/app/tests/services/credentials/test_reencrypt.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import base64
import json

import boto3
from moto import mock_aws
from sqlmodel import Session

import app.core.security as security
from app.core.config import settings
from app.core.security import KMS_CIPHERTEXT_PREFIX, decrypt_credentials
from app.core.security import (
KMS_CIPHERTEXT_PREFIX,
KMS_ENVELOPE_PREFIX,
decrypt_credentials,
)
from app.models import Credential
from app.services.credentials.reencrypt import execute_credential_reencrypt
from app.tests.utils.test_data import create_test_credential
Expand All @@ -29,5 +36,35 @@ def test_reencrypt_converts_fernet_rows_to_kms(db: Session, monkeypatch):
assert result["converted"] >= len(expected)
for cid, plain in expected.items():
row = db.get(Credential, cid)
assert row.credential.startswith(KMS_CIPHERTEXT_PREFIX)
assert row.credential.startswith(KMS_ENVELOPE_PREFIX)
assert decrypt_credentials(row.credential) == plain


def test_reencrypt_converts_v1_row_to_v2(db: Session, monkeypatch):
creds, _ = create_test_credential(db)
expected = {c.id: decrypt_credentials(c.credential) for c in creds}

with mock_aws():
client = boto3.client("kms", region_name="ap-south-1")
key_id = client.create_key()["KeyMetadata"]["KeyId"]
monkeypatch.setattr(settings, "ENVIRONMENT", "staging")
monkeypatch.setattr(settings, "AWS_KMS_KEY_ID", key_id)
monkeypatch.setattr(security, "_kms_client", client)

# Rewrite each row as a legacy direct-KMS (v1) ciphertext.
for c in creds:
plain = expected[c.id]
blob = client.encrypt(KeyId=key_id, Plaintext=json.dumps(plain).encode())[
"CiphertextBlob"
]
c.credential = KMS_CIPHERTEXT_PREFIX + base64.b64encode(blob).decode()
db.add(c)
db.commit()

result = execute_credential_reencrypt(session=db)

assert result["converted"] >= len(expected)
for cid, plain in expected.items():
row = db.get(Credential, cid)
assert row.credential.startswith(KMS_ENVELOPE_PREFIX)
assert decrypt_credentials(row.credential) == plain
2 changes: 1 addition & 1 deletion docs/wiki/modules/platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ All paths relative to `backend/app/`.
| Notifications | — | `notification` (`models/notification.py`) | `services/notifications/`, `crud/notification.py` |
| Feature flags | `api/routes/features.py` | `feature_flag` (`models/feature_flag.py`) | `core/feature_flags/`, `crud/feature_flag.py` |
| Languages | `api/routes/languages.py` | `global.languages` (`models/language.py`) | `crud/language.py` |
| Credentials | `api/routes/credentials.py` | `credential` (`models/credentials.py`) | `crud/credentials.py`; provider keys per org/project |
| Credentials | `api/routes/credentials.py` | `credential` (`models/credentials.py`) | `crud/credentials.py`; provider keys per org/project; envelope encryption (KMS-wrapped data key + AES-GCM), prefix-versioned ciphertexts |
| Model config | `api/routes/model_config.py` | `model_config` (`models/model_config.py`) | `crud/model_config.py` |
| Cron | `api/routes/cron.py` | — | triggers batch polling (`crud/evaluations/cron.py`) |
| Jobs | — | `job` (`models/job.py`), `batch_job` (`models/batch_job.py`) | `crud/jobs.py`, `crud/job/`, `services/job_monitoring.py` |
Loading