diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index df67ff3..c66d806 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -39,8 +39,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - name: Run tests - run: cargo test --all + - name: Run unit tests + run: cargo test --bins build: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2eeba49 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,101 @@ +name: Tests + +permissions: + id-token: write + contents: read + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + bicep: + name: Deploy Azure resources + environment: bicep + runs-on: ubuntu-latest + concurrency: + group: bicep + env: + LOCATION: eastus + DEPLOYMENT_NAME: keyweave-${{ github.run_id }} + steps: + - uses: actions/checkout@v3 + - uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Deploy Bicep template + uses: azure/arm-deploy@v1 + with: + scope: subscription + region: ${{ env.LOCATION }} + template: bicep/main.bicep + deploymentName: ${{ env.DEPLOYMENT_NAME }} + + tests-no-access: + name: Tests with No Access + needs: bicep + runs-on: ubuntu-latest + environment: test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 'Az CLI login' + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID_NO_ACCESS }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Run all tests + run: cargo test no_access + tests-get: + name: Tests with Get + needs: bicep + runs-on: ubuntu-latest + environment: test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 'Az CLI login' + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID_GET }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Run all tests + run: cargo test only_get + tests-list: + name: Tests with List + needs: bicep + runs-on: ubuntu-latest + environment: test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 'Az CLI login' + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID_LIST }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Run all tests + run: cargo test only_list + tests-get-list: + name: Tests with Get and List + needs: bicep + runs-on: ubuntu-latest + environment: test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 'Az CLI login' + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID_GET_LIST }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Run all tests + run: cargo test get_and_list_access diff --git a/Cargo.lock b/Cargo.lock index d58f8b6..26084b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -86,6 +95,36 @@ version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[package]] +name = "assert_cmd" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88903cb14723e4d4003335bb7f8a14f27691649105346a0f0957466c096adfe6" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f070617a68e5c2ed5d06ee8dd620ee18fb72b99f6c094bed34cf8ab07c875b48" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "async-channel" version = "1.9.0" @@ -235,6 +274,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -382,6 +432,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.2", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.9" @@ -392,6 +455,12 @@ dependencies = [ "serde", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -402,12 +471,24 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -469,6 +550,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -643,6 +733,30 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +[[package]] +name = "globset" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.3.21" @@ -668,6 +782,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" + [[package]] name = "heck" version = "0.4.1" @@ -804,6 +924,23 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -811,7 +948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -835,6 +972,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -855,12 +1001,17 @@ name = "keyweave" version = "0.2.3" dependencies = [ "anyhow", + "assert_cmd", + "assert_fs", + "azure_core", "azure_identity", "azure_security_keyvault", "clap", "futures", "openssl", "paris", + "predicates", + "serial_test", "tokio", ] @@ -948,6 +1099,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.17" @@ -1161,6 +1318,37 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfc28575c2e3f19cb3c73b93af36460ae898d426eba6fc15b9bd2a5220758a0" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "itertools", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.69" @@ -1259,6 +1447,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "reqwest" version = "0.11.22" @@ -1333,6 +1550,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.22" @@ -1441,6 +1667,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +dependencies = [ + "dashmap", + "futures", + "lazy_static", + "log", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1547,6 +1798,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.50" @@ -1567,6 +1824,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.30" @@ -1774,12 +2041,31 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690" +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1906,6 +2192,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 57e9320..b1ddc77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/bartvdbraak/keyweave/" [dependencies] anyhow = "1.0.75" +azure_core = "0.17.0" azure_identity = "0.17.0" azure_security_keyvault = "0.17.0" clap = { version = "4.4.8", features = ["derive"] } @@ -20,3 +21,9 @@ tokio = {version = "1.34.0", features = ["full"]} [target.'cfg(all(target_os = "linux", any(target_env = "musl", target_arch = "arm", target_arch = "aarch64")))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } + +[dev-dependencies] +assert_cmd = "2.0.12" +assert_fs = "1.0.13" +predicates = "3.0.4" +serial_test = "2.0.0" diff --git a/bicep/main.bicep b/bicep/main.bicep new file mode 100644 index 0000000..9aa1534 --- /dev/null +++ b/bicep/main.bicep @@ -0,0 +1,85 @@ +targetScope = 'subscription' + +/* + Parameters +*/ + +@allowed([ + 'D' // Development + 'T' // Test + 'A' // Acceptance + 'P' // Production +]) +param environment string = 'T' +param location string = 'westeurope' +param name object = { + tenantId: 'BVDB' + projectId: 'KEYWEAVE' + region: 'WEU' +} + +/* + Variables +*/ + +var tags = { + project: 'keyweave' +} +var nameFormat = '${name.tenantId}-${name.projectId}-${environment}-${name.region}-{0}-{1:N0}' + +/* + Resource Group +*/ + +resource ResourceGroup 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: format(nameFormat, 'RG', 1) + location: location + tags: tags +} + +/* + Module for Log Analytics Workspace +*/ + +module LogAnalyticsWorkspace 'modules/law.bicep' = { + name: 'LogAnalyticsWorkspace' + scope: ResourceGroup + params: { + nameFormat: nameFormat + location: location + tags: tags + } +} + +/* + Module for Managed Identities +*/ + +module ManagedIdentities 'modules/id.bicep' = { + name: 'ManagedIdentities' + scope: ResourceGroup + params: { + nameFormat: nameFormat + location: location + tags: tags + } +} + +/* + Module for KeyVault +*/ + +module KeyVault 'modules/kv.bicep' = { + name: 'KeyVault' + scope: ResourceGroup + dependsOn: [ + LogAnalyticsWorkspace + ] + params: { + nameFormat: nameFormat + location: location + tags: tags + + identities: ManagedIdentities.outputs.identities + } +} diff --git a/bicep/modules/id.bicep b/bicep/modules/id.bicep new file mode 100644 index 0000000..3f95431 --- /dev/null +++ b/bicep/modules/id.bicep @@ -0,0 +1,33 @@ +param nameFormat string +param location string +param tags object + +param identityEnvironments array = [ + 'none' + 'get' + 'list' + 'getlist' +] + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = [for (environment, index) in identityEnvironments: { + name: format(nameFormat, 'ID', index+1) + location: location + tags: tags +}] + +resource federatedCredential 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31' = [for (environment, index) in identityEnvironments: { + name: environment + parent: managedIdentity[index] + properties: { + issuer: 'https://token.actions.githubusercontent.com' + subject: 'repo:bartvdbraak/keyweave:environment:test' + audiences: [ + 'api://AzureADTokenExchange' + ] + } +}] + +output identities array = [for (environment, index) in identityEnvironments: { + name: environment + id: managedIdentity[index].properties.principalId +}] diff --git a/bicep/modules/kv.bicep b/bicep/modules/kv.bicep new file mode 100644 index 0000000..de4d025 --- /dev/null +++ b/bicep/modules/kv.bicep @@ -0,0 +1,121 @@ +param nameFormat string +param location string +param tags object + +param identities array + +var accessPolicies = [for identity in identities: { + tenantId: tenant().tenantId + objectId: identity.id + permissions: { + secrets: contains(identity.name, 'get') && contains(identity.name, 'list') ? ['Get', 'List'] : contains(identity.name, 'get') ? ['Get'] : contains(identity.name, 'list') ? ['List'] : [] + } +}] + +/* + Log Analytics Workspace (existing) +*/ + +resource _logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { + name: format(nameFormat, 'LAW', 1) +} + +/* + Key Vault +*/ + +resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' = { + name: replace(toLower(format(nameFormat, 'KVT', 1)), '-', '') + location: location + tags: tags + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: tenant().tenantId + enableSoftDelete: true + enablePurgeProtection: true + accessPolicies: accessPolicies + } + resource testSecret 'secrets' = { + name: 'testSecret' + properties: { + value: 'testSecretValue' + } + } + resource filterTestSecret 'secrets' = { + name: 'filterTestSecret' + properties: { + value: 'filterTestSecretValue' + } + } +} + +/* + Key Vault +*/ + +resource keyVaultWithFirewall 'Microsoft.KeyVault/vaults@2023-02-01' = { + name: replace(toLower(format(nameFormat, 'KVT', 2)), '-', '') + location: location + tags: tags + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: tenant().tenantId + enableSoftDelete: true + enablePurgeProtection: true + accessPolicies: accessPolicies + networkAcls: { + defaultAction: 'Deny' + ipRules: [] + } + } + resource testSecret 'secrets' = { + name: 'testSecret' + properties: { + value: 'testSecretValue' + } + } + resource filterTestSecret 'secrets' = { + name: 'filterTestSecret' + properties: { + value: 'filterTestSecretValue' + } + } +} + +/* + Diagnostic Settings for Key Vaults +*/ + +resource keyVaultDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'keyVaultLogging' + scope: keyVault + properties: { + workspaceId: _logAnalyticsWorkspace.id + logs: [ + { + category: 'AuditEvent' + enabled: true + } + ] + } +} + +resource keyVaultWithFirewallDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'keyVaultLogging' + scope: keyVaultWithFirewall + properties: { + workspaceId: _logAnalyticsWorkspace.id + logs: [ + { + category: 'AuditEvent' + enabled: true + } + ] + } +} diff --git a/bicep/modules/law.bicep b/bicep/modules/law.bicep new file mode 100644 index 0000000..451519c --- /dev/null +++ b/bicep/modules/law.bicep @@ -0,0 +1,24 @@ +param nameFormat string +param location string +param tags object + +/* + Log Analytics Workspace +*/ + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { + name: format(nameFormat, 'LAW', 1) + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + workspaceCapping: { + dailyQuotaGb: json('0.025') + } + } +} diff --git a/src/main.rs b/src/main.rs index af80d3e..59fe640 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use azure_core::error::HttpError; use azure_identity::DefaultAzureCredential; use azure_security_keyvault::prelude::KeyVaultGetSecretsResponse; use azure_security_keyvault::KeyvaultClient; @@ -27,6 +28,23 @@ struct Opts { filter: Option, } +async fn check_vault_dns(vault_name: &str) -> Result<()> { + let vault_host = format!("{}.vault.azure.net", vault_name); + + let lookup_result = { tokio::net::lookup_host((vault_host.as_str(), 443)).await }; + + match lookup_result { + Ok(_) => Ok(()), + Err(err) => { + error!("DNS lookup failed for Key Vault: {}", vault_name); + error!( + "Please check that the Key Vault exists or that you have no connectivity issues." + ); + Err(err.into()) + } + } +} + async fn fetch_secrets_from_key_vault( client: &KeyvaultClient, filter: Option<&str>, @@ -38,8 +56,27 @@ async fn fetch_secrets_from_key_vault( let page = match page { Ok(p) => p, Err(err) => { - error!("Failed to fetch secrets page: {}", err); - return Err(err.into()); // Convert the error into anyhow::Error + error!("\n"); + error!("Failed to fetch secrets."); + let specific_error = err.downcast_ref::(); + if let Some(specific_error) = specific_error { + if specific_error + .error_message() + .unwrap() + .to_string() + .contains("does not have secrets list permission on key vault") + { + error!("Make sure you have List permissions on the Key Vault."); + } else if specific_error + .error_message() + .unwrap() + .to_string() + .contains("is not authorized and caller is not a trusted service") + { + error!("Make sure you're on the Key Vaults Firewall allowlist."); + } + } + return Err(err.into()); } }; secret_values @@ -105,8 +142,8 @@ async fn fetch_and_send_secret( let _ = tx.send((secret_id.clone(), bundle.value.clone())).await; (secret_id, bundle.value) } - Err(err) => { - error!("Error fetching secret: {}", err); + Err(_err) => { + error!("Error fetching secret. Make sure you have Get permissions on the Key Vault."); (secret_id, String::new()) } } @@ -158,7 +195,7 @@ mod tests { vec!["SECRET_KEY=secret_value1", "API_KEY=secret_value2",] ); - fs::remove_file(test_file)?; // Clean up the test file + fs::remove_file(test_file)?; Ok(()) } } @@ -181,6 +218,8 @@ async fn main() -> Result<()> { }; log.success("Detected credentials."); + check_vault_dns(&opts.vault_name).await?; + log.loading(format!( "Fetching secrets from Key Vault: {}", opts.vault_name diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..0b2332f --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,151 @@ +use assert_cmd::prelude::*; +use assert_fs::prelude::*; +use assert_fs::TempDir; +use predicates::prelude::*; +use serial_test::serial; +use std::process::Command; + +static BINARY: &str = "keyweave"; +static KEYVAULT: &str = "bvdbkeyweavetweukvt1"; +static FIREWALL_KEYVAULT: &str = "bvdbkeyweavetweukvt2"; +static NON_EXISTENT_KEYVAULT: &str = "bvdbkeyweavetweukvt3"; + +#[tokio::test] +#[serial] +async fn test_no_access_policies() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().failure().stderr(predicate::str::contains( + "Make sure you have List permissions on the Key Vault.", + )); + + temp_dir.close().unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_only_get_access_policy() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().failure().stderr(predicate::str::contains( + "Make sure you have List permissions on the Key Vault.", + )); + + temp_dir.close().unwrap(); +} + +/// Test with only List access policy - expected to succeed with get errors. +#[tokio::test] +#[serial] +async fn test_only_list_access_policy() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().success().stderr(predicate::str::contains( + "Make sure you have Get permissions on the Key Vault.", + )); + + temp_dir.close().unwrap(); +} + +/// Test with both Get and List access policies - expected to pass. +#[tokio::test] +#[serial] +async fn test_get_and_list_access_policies() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().success(); + + output_path.assert(predicate::path::is_file()); + output_path.assert(predicate::str::contains("testSecret=testSecretValue")); + output_path.assert(predicate::str::contains( + "filterTestSecret=filterTestSecretValue", + )); + + temp_dir.close().unwrap(); +} + +/// Test with both Get and List access policies and filter - expected to pass. +#[tokio::test] +#[serial] +async fn test_get_and_list_access_policies_filter() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(KEYVAULT) + .arg("--output") + .arg(output_path.path()) + .arg("--filter") + .arg("filter"); + cmd.assert().success(); + + output_path.assert(predicate::path::is_file()); + output_path.assert(predicate::str::contains( + "filterTestSecret=filterTestSecretValue", + )); + + temp_dir.close().unwrap(); +} + +/// Test with both Get and List access policies on a Key Vault with Firewall - expected to fail. +#[tokio::test] +#[serial] +async fn test_get_and_list_access_policies_firewall() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(FIREWALL_KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().failure().stderr(predicate::str::contains( + "Make sure you're on the Key Vaults Firewall allowlist.", + )); + + temp_dir.close().unwrap(); +} + +/// Test with both Get and List access policies on a non-existent Key Vault - expected to fail. +#[tokio::test] +#[serial] +async fn test_get_and_list_access_policies_non_existent() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.child(".env"); + + let mut cmd = Command::cargo_bin(BINARY).unwrap(); + cmd.arg("--vault-name") + .arg(NON_EXISTENT_KEYVAULT) + .arg("--output") + .arg(output_path.path()); + cmd.assert().failure().stderr(predicate::str::contains( + "Please check that the Key Vault exists or that you have no connectivity issues.", + )); + + temp_dir.close().unwrap(); +}