-
Notifications
You must be signed in to change notification settings - Fork 10
feat(security): Implement KMS encryption for credentials #1117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c5601bc
4856e94
8398f36
7fdefac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.pyRepository: 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))
EOFRepository: 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)
PYRepository: ProjectTech4DevAI/kaapi-backend Length of output: 1346 Add return annotations to Both migration functions return 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/appRepository: 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")
PYRepository: 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.pyRepository: ProjectTech4DevAI/kaapi-backend Length of output: 210 Use a narrow JSON value type for
🤖 Prompt for AI AgentsSource: 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): | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | " | ||
|
|
@@ -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): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| plaintext = decrypt_credentials(row.credential) | ||
| new_ciphertext = encrypt_credentials(plaintext) | ||
| if decrypt_credentials(new_ciphertext) != plaintext: | ||
|
|
@@ -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} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import base64 | ||
| import json | ||
| from datetime import timedelta | ||
|
|
||
| import boto3 | ||
|
|
@@ -11,6 +13,7 @@ | |
| from app.core.security import ( | ||
| ALGORITHM, | ||
| KMS_CIPHERTEXT_PREFIX, | ||
| KMS_ENVELOPE_PREFIX, | ||
| APIKeyManager, | ||
| create_access_token, | ||
| create_refresh_token, | ||
|
|
@@ -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): | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]}")
PYRepository: 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]}")
PYRepository: ProjectTech4DevAI/kaapi-backend Length of output: 12407 Add narrow type hints to the new KMS credential tests. The test method signatures in 🧰 Tools🪛 ast-grep (0.45.0)[info] 100-100: use jsonify instead of json.dumps for JSON output (use-jsonify) [info] 110-110: use jsonify instead of json.dumps for JSON output (use-jsonify) 📍 Affects 3 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
|
|
||
| class TestAPIKeyManager: | ||
| """Test suite for APIKeyManager class.""" | ||
|
|
||
There was a problem hiding this comment.
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_reencryptThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.