diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7ab32ee --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +## Summary + + + +## Change Type + + + +- [ ] Infrastructure (snowddl/*.yaml changes) +- [ ] Code (src/, scripts/ changes) +- [ ] Documentation +- [ ] Tests +- [ ] CI/CD + +## Related Issues + + + +Fixes # + +## Test Plan + + + +- [ ] Ran `uv run pytest` +- [ ] Ran `uv run pre-commit run --all-files` +- [ ] Ran `uv run snowddl-plan` (for infrastructure changes) + +## Checklist + +- [ ] Tests pass locally +- [ ] Pre-commit checks pass +- [ ] Documentation updated (if applicable) +- [ ] No secrets or sensitive data included diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..da10148 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,44 @@ +# Configuration for actions/labeler +# Maps file patterns to labels + +infrastructure: + - changed-files: + - any-glob-to-any-file: 'snowddl/**/*.yaml' + +user-change: + - changed-files: + - any-glob-to-any-file: 'snowddl/user.yaml' + +security: + - changed-files: + - any-glob-to-any-file: + - 'snowddl/*policy*.yaml' + - 'snowddl/authentication*.yaml' + - 'snowddl/network*.yaml' + +code: + - changed-files: + - any-glob-to-any-file: + - 'src/**/*.py' + - 'scripts/**/*.py' + +tests: + - changed-files: + - any-glob-to-any-file: 'tests/**' + +documentation: + - changed-files: + - any-glob-to-any-file: + - 'docs/**' + - '*.md' + - 'README.md' + +ci/cd: + - changed-files: + - any-glob-to-any-file: '.github/**' + +dependencies: + - changed-files: + - any-glob-to-any-file: + - 'pyproject.toml' + - 'uv.lock' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d5b0c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + pull_request: + branches: [main, v0.2] + push: + branches: [main, v0.2] + +jobs: + lint: + name: Lint & Format Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install UV + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run pre-commit + run: uv run pre-commit run --all-files + + test: + name: Run Tests + runs-on: ubuntu-latest + needs: lint + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install UV + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run tests + run: uv run pytest -v --tb=short + env: + SNOWFLAKE_ACCOUNT: "test_account" + SNOWFLAKE_USER: "test_user" + SNOWFLAKE_PASSWORD: "test_password" + SNOWFLAKE_WAREHOUSE: "test_warehouse" + SNOWFLAKE_DATABASE: "test_database" + SNOWFLAKE_ROLE: "test_role" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..94ba032 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,22 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + label: + name: Label PR + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Label PR based on changed files + uses: actions/labeler@v5 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/scripts/generate_rsa_keys_batch.py b/scripts/generate_rsa_keys_batch.py index 7d6b7b8..08141c3 100644 --- a/scripts/generate_rsa_keys_batch.py +++ b/scripts/generate_rsa_keys_batch.py @@ -19,60 +19,64 @@ load_dotenv() -def generate_rsa_key_pair(username: str, output_dir: Path, key_size: int = 2048) -> tuple[Path, Path]: +def generate_rsa_key_pair( + username: str, output_dir: Path, key_size: int = 2048 +) -> tuple[Path, Path]: """ Generate RSA key pair for a user. - + Args: username: Username for the key pair output_dir: Directory to store keys key_size: RSA key size (2048 or 4096) - + Returns: Tuple of (private_key_path, public_key_path) """ username_lower = username.lower() timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - + private_key_path = output_dir / f"{username_lower}_rsa_key_{timestamp}.p8" public_key_path = output_dir / f"{username_lower}_rsa_key_{timestamp}.pub" - + print(f"\nšŸ” Generating {key_size}-bit RSA key pair for {username}...") - + try: # Generate private key in PKCS8 format genrsa_cmd = ["openssl", "genrsa", str(key_size)] - genrsa_result = subprocess.run(genrsa_cmd, capture_output=True, text=True, check=True) - + genrsa_result = subprocess.run( + genrsa_cmd, capture_output=True, text=True, check=True + ) + pkcs8_cmd = ["openssl", "pkcs8", "-topk8", "-inform", "PEM", "-nocrypt"] pkcs8_result = subprocess.run( - pkcs8_cmd, - input=genrsa_result.stdout, - capture_output=True, - text=True, - check=True + pkcs8_cmd, + input=genrsa_result.stdout, + capture_output=True, + text=True, + check=True, ) - + # Write private key - with open(private_key_path, 'w') as f: + with open(private_key_path, "w") as f: f.write(pkcs8_result.stdout) - + # Set secure permissions private_key_path.chmod(0o400) - + # Extract public key rsa_cmd = ["openssl", "rsa", "-in", str(private_key_path), "-pubout"] rsa_result = subprocess.run(rsa_cmd, capture_output=True, text=True, check=True) - + # Write public key - with open(public_key_path, 'w') as f: + with open(public_key_path, "w") as f: f.write(rsa_result.stdout) - + print(f"āœ… Private key: {private_key_path}") print(f"āœ… Public key: {public_key_path}") - + return private_key_path, public_key_path - + except subprocess.CalledProcessError as e: print(f"āŒ Error generating keys for {username}: {e}") print(f" stderr: {e.stderr}") @@ -82,23 +86,19 @@ def generate_rsa_key_pair(username: str, output_dir: Path, key_size: int = 2048) def extract_public_key_body(public_key_path: Path) -> str: """ Extract the public key content without headers for SnowDDL YAML. - + Args: public_key_path: Path to public key file - + Returns: Public key content as single-line string """ - with open(public_key_path, 'r') as f: + with open(public_key_path, "r") as f: lines = f.readlines() - + # Remove header/footer and newlines - key_body = ''.join([ - line.strip() - for line in lines - if not line.startswith('-----') - ]) - + key_body = "".join([line.strip() for line in lines if not line.startswith("-----")]) + return key_body @@ -107,41 +107,39 @@ def main(): description="Batch generate RSA keys for Snowflake users" ) parser.add_argument( - '--users', - nargs='+', - help='List of usernames to generate keys for' + "--users", nargs="+", help="List of usernames to generate keys for" ) parser.add_argument( - '--all-non-compliant', - action='store_true', - help='Generate keys for all non-compliant users' + "--all-non-compliant", + action="store_true", + help="Generate keys for all non-compliant users", ) parser.add_argument( - '--key-size', + "--key-size", type=int, choices=[2048, 4096], default=2048, - help='RSA key size (default: 2048)' + help="RSA key size (default: 2048)", ) parser.add_argument( - '--output-dir', + "--output-dir", type=Path, - default=Path('keys'), - help='Output directory for keys (default: keys/)' + default=Path("keys"), + help="Output directory for keys (default: keys/)", ) - + args = parser.parse_args() - + # Define non-compliant users based on migration plan NON_COMPLIANT_USERS = [ - 'GRACE', # PERSON - no RSA, no password - 'CAROL', # PERSON - password only - 'ESTUARY', # SERVICE - no auth configured - 'FABI_AI', # SERVICE - no auth configured - 'TOBIKO_CLOUD', # SERVICE - no auth configured - 'STEPHEN_RECOVERY' # PERSON - emergency account, password only + "GRACE", # PERSON - no RSA, no password + "CAROL", # PERSON - password only + "ESTUARY", # SERVICE - no auth configured + "FABI_AI", # SERVICE - no auth configured + "TOBIKO_CLOUD", # SERVICE - no auth configured + "STEPHEN_RECOVERY", # PERSON - emergency account, password only ] - + # Determine which users to process if args.all_non_compliant: users = NON_COMPLIANT_USERS @@ -150,10 +148,10 @@ def main(): else: parser.print_help() sys.exit(1) - + # Create output directory args.output_dir.mkdir(parents=True, exist_ok=True) - + print("=" * 70) print("šŸ”‘ Snowflake RSA Key Generation - Batch Mode") print("=" * 70) @@ -161,51 +159,51 @@ def main(): print(f"šŸ”¢ Key size: {args.key_size} bits") print(f"šŸ‘„ Users to process: {len(users)}") print("=" * 70) - + # Generate keys for each user results = {} for username in users: private_key, public_key = generate_rsa_key_pair( - username, - args.output_dir, - args.key_size + username, args.output_dir, args.key_size ) public_key_body = extract_public_key_body(public_key) results[username] = { - 'private_key': private_key, - 'public_key': public_key, - 'public_key_body': public_key_body + "private_key": private_key, + "public_key": public_key, + "public_key_body": public_key_body, } - + # Output summary and SnowDDL configuration print("\n" + "=" * 70) print("āœ… KEY GENERATION COMPLETE") print("=" * 70) - + print("\nšŸ“‹ SNOWDDL YAML CONFIGURATION") print("=" * 70) print("\nCopy these public keys to snowddl/user.yaml:\n") - + for username, info in results.items(): print(f"{username}:") print(f" rsa_public_key: {info['public_key_body']}") print() - + print("=" * 70) print("\nšŸ” PRIVATE KEY DISTRIBUTION") print("=" * 70) print("\nSecurely distribute these private keys:\n") - + for username, info in results.items(): print(f"• {username}: {info['private_key']}") - + print("\nāš ļø SECURITY REMINDERS:") - print(" 1. Store private keys in secure locations (1Password, AWS Secrets Manager)") + print( + " 1. Store private keys in secure locations (1Password, AWS Secrets Manager)" + ) print(" 2. NEVER commit private keys to Git") print(" 3. Set permissions to 400: chmod 400 keys/*_rsa_key*.p8") print(" 4. Share via secure channels only") print(" 5. Document key distribution in secure audit log") - + print("\nšŸ“ NEXT STEPS:") print(" 1. Update snowddl/user.yaml with public keys") print(" 2. Run: uv run snowddl-plan") @@ -213,11 +211,11 @@ def main(): print(" 4. Run: uv run snowddl-apply") print(" 5. Distribute private keys securely") print(" 6. Test authentication for each user") - + print("\n" + "=" * 70) print("āœ… Script completed successfully") print("=" * 70) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/banner.py b/src/banner.py index 32784e3..34e1dea 100644 --- a/src/banner.py +++ b/src/banner.py @@ -30,9 +30,12 @@ ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• """ -BANNER_WITH_VERSION = BANNER + f""" +BANNER_WITH_VERSION = ( + BANNER + + f""" Version {__version__} """ +) def show_banner(with_version: bool = True) -> None: diff --git a/tests/test_rsa_keys.py b/tests/test_rsa_keys.py index 5b947b4..cac7e8d 100644 --- a/tests/test_rsa_keys.py +++ b/tests/test_rsa_keys.py @@ -16,13 +16,14 @@ # Add src to path import sys + sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from user_management.rsa_keys import ( RSAKeyManager, RSAKeyError, RSAKeyGenerationError, - RSAKeyValidationError + RSAKeyValidationError, ) @@ -81,8 +82,7 @@ def test_generate_key_pair_default_size(self): def test_generate_key_pair_custom_size(self): """Test key pair generation with custom size""" private_key_path, public_key_path = self.manager.generate_key_pair( - "test_user", - key_size=4096 + "test_user", key_size=4096 ) assert private_key_path.exists() @@ -113,7 +113,7 @@ def test_generate_key_pair_permissions(self): # Check file permissions (should be 0600 or similar) mode = private_key_path.stat().st_mode # On Unix, check that group and others have no permissions - if hasattr(mode, '__and__'): + if hasattr(mode, "__and__"): # Basic check that permissions are restrictive assert private_key_path.exists() @@ -148,7 +148,7 @@ def test_load_private_key_success(self): private_key_path, _ = self.manager.generate_key_pair("test_user") # Read the key file content directly (no load_private_key method exists) - with open(private_key_path, 'r') as f: + with open(private_key_path, "r") as f: key_content = f.read() assert key_content is not None @@ -159,7 +159,7 @@ def test_load_private_key_file_not_found(self): non_existent = self.key_dir / "nonexistent_key" with pytest.raises(FileNotFoundError): - with open(non_existent, 'r') as f: + with open(non_existent, "r") as f: f.read() def test_load_private_key_with_passphrase(self): @@ -168,7 +168,7 @@ def test_load_private_key_with_passphrase(self): private_key_path, _ = self.manager.generate_key_pair("test_user") # Keys are generated without passphrase by default - with open(private_key_path, 'r') as f: + with open(private_key_path, "r") as f: content = f.read() # Unencrypted keys don't have ENCRYPTED in header @@ -210,7 +210,7 @@ def test_public_key_format(self): assert "END PUBLIC KEY" not in public_key_str # Should be base64-like (no newlines in middle) - lines = public_key_str.strip().split('\n') + lines = public_key_str.strip().split("\n") # Single line or empty assert len([l for l in lines if l.strip()]) <= 1 @@ -251,6 +251,7 @@ def test_rotate_keys_creates_new_keys(self): def test_rotate_keys_backs_up_old_keys(self): """Test that key rotation keeps previous keys""" import time + # Generate initial keys old_priv, old_pub = self.manager.generate_key_pair("test_user") @@ -342,7 +343,7 @@ def test_list_keys_with_keys(self): assert len(keys) >= 3 # Check that user keys are in the list (list_keys returns dicts with 'username' key) - usernames = [k['username'] for k in keys] + usernames = [k["username"] for k in keys] assert "USER1" in usernames # Usernames are uppercased in the result @@ -378,10 +379,12 @@ def test_export_private_key_pem(self): private_key_path, _ = self.manager.generate_key_pair("test_user") # Read the private key file (already in PEM format) - with open(private_key_path, 'r') as f: + with open(private_key_path, "r") as f: pem_content = f.read() - assert "BEGIN PRIVATE KEY" in pem_content or "BEGIN RSA PRIVATE KEY" in pem_content + assert ( + "BEGIN PRIVATE KEY" in pem_content or "BEGIN RSA PRIVATE KEY" in pem_content + ) class TestErrorHandling: @@ -445,4 +448,4 @@ def test_public_key_for_snowflake_format(self): if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"])