├── .drone.yml ├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml ├── issue_commands.json ├── pr-commands.json ├── workflows │ ├── issue_commands.yml │ ├── pr-commands.yml │ └── stale.yml └── zizmor.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Magefile.go ├── README.md ├── go.mod ├── go.sum └── pkg ├── awsauth ├── api_client.go ├── auth.go ├── auth_test.go ├── authtype.go ├── settings.go ├── test_utils.go └── testdata │ ├── assume_role_credentials │ ├── credentials │ └── shared_credentials ├── awsds ├── asyncDatasource.go ├── asyncDatasource_test.go ├── authSettings.go ├── authSettings_test.go ├── query.go ├── sessions.go ├── sessions_test.go ├── settings.go ├── settings_test.go ├── types.go ├── utils.go └── utils_test.go ├── cloudWatchConsts ├── metrics.go └── metrics_test.go ├── sigv4 ├── sigv4.go ├── sigv4_middleware.go ├── sigv4_middleware_test.go └── sigv4_test.go └── sql ├── api ├── api.go └── api_test.go ├── datasource ├── datasource.go ├── datasource_test.go ├── utils.go └── utils_test.go ├── driver ├── async │ ├── connection.go │ └── driver.go └── driver.go ├── models └── models.go └── routes ├── routes.go └── routes_test.go /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This file must be signed. You can do so with the `mage drone` command 3 | 4 | kind: pipeline 5 | type: docker 6 | name: 7 | 8 | platform: 9 | os: linux 10 | arch: amd64 11 | 12 | steps: 13 | - name: build 14 | image: grafana/grafana-plugin-ci:1.9.5 15 | commands: 16 | - mage --keep -v build 17 | 18 | - name: lint 19 | image: golangci/golangci-lint:v1.64.2 20 | commands: 21 | - golangci-lint run ./... 22 | 23 | - name: test 24 | image: grafana/grafana-plugin-ci:1.9.5 25 | commands: 26 | - mage --keep -v test 27 | 28 | - name: vuln check 29 | image: golang:1.24.2 30 | depends_on: [clone] 31 | commands: 32 | - go install golang.org/x/vuln/cmd/govulncheck@latest 33 | - govulncheck ./... 34 | 35 | --- 36 | kind: signature 37 | hmac: 96b53fa91bf5f81092fd0f4e8780122d2eaae5ab3c1be3908ae7178874db163a 38 | 39 | ... 40 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | 12 | [*.go] 13 | indent_style = tab 14 | indent_size = 4 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.{js,ts,tsx,scss}] 20 | quote_type = single 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/aws-datasources 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug 4 | labels: ["grafana-aws-sdk", "type/bug"] 5 | --- 6 | 7 | 15 | 16 | **What happened**: 17 | 18 | **What you expected to happen**: 19 | 20 | **How to reproduce it (as minimally and precisely as possible)**: 21 | 22 | **Screenshots** 23 | 24 | 27 | 28 | **Anything else we need to know?**: 29 | 30 | **Environment**: 31 | 32 | - Grafana version: 33 | - Sdk version: 34 | - OS Grafana is installed on: 35 | - User OS & Browser: 36 | - Others: 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Questions & Help 4 | url: https://community.grafana.com 5 | about: Please ask and answer questions here 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: ["type/feature-request"] 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | all-go-dependencies: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | all-github-action-dependencies: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/issue_commands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "label", 4 | "name": "grafana-aws-sdk", 5 | "action": "addToProject", 6 | "addToProject": { 7 | "url": "https://github.com/orgs/grafana/projects/97" 8 | } 9 | }, 10 | { 11 | "type": "label", 12 | "name": "grafana-aws-sdk", 13 | "action": "removeFromProject", 14 | "removeFromProject": { 15 | "url": "https://github.com/orgs/grafana/projects/97" 16 | } 17 | }, 18 | { 19 | "type": "label", 20 | "name": "type/docs", 21 | "action": "addToProject", 22 | "addToProject": { 23 | "url": "https://github.com/orgs/grafana/projects/69" 24 | } 25 | }, 26 | { 27 | "type": "label", 28 | "name": "type/docs", 29 | "action": "removeFromProject", 30 | "removeFromProject": { 31 | "url": "https://github.com/orgs/grafana/projects/69" 32 | } 33 | } 34 | ] -------------------------------------------------------------------------------- /.github/pr-commands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "author", 4 | "name": "pr/external", 5 | "notMemberOf": { 6 | "org": "grafana" 7 | }, 8 | "ignoreList": ["renovate[bot]", "dependabot[bot]", "grafana-delivery-bot[bot]", "grafanabot"], 9 | "action": "updateLabel", 10 | "addLabel": "pr/external" 11 | }, 12 | { 13 | "type": "label", 14 | "name": "pr/external", 15 | "action": "addToProject", 16 | "addToProject": { 17 | "url": "https://github.com/orgs/grafana/projects/97", 18 | "column": "Incoming" 19 | } 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /.github/workflows/issue_commands.yml: -------------------------------------------------------------------------------- 1 | name: Run commands when issues are labeled 2 | on: 3 | issues: 4 | types: [labeled, unlabeled] 5 | permissions: 6 | contents: read 7 | issues: write 8 | 9 | jobs: 10 | main: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | # The "id-token: write" permission is required by "get-vault-secrets" action 15 | id-token: write 16 | steps: 17 | - name: Checkout Actions 18 | uses: actions/checkout@v4 19 | with: 20 | repository: "grafana/grafana-github-actions" 21 | path: ./actions 22 | ref: main 23 | persist-credentials: false 24 | - name: Install Actions 25 | run: npm install --production --prefix ./actions 26 | - name: Get secrets from vault 27 | id: get-secrets 28 | uses: grafana/shared-workflows/actions/get-vault-secrets@main 29 | with: 30 | repo_secrets: | 31 | AWS_DS_TOKEN_CREATOR_ID=aws-ds-token-creator:app_id 32 | AWS_DS_TOKEN_CREATOR_PEM=aws-ds-token-creator:pem 33 | - name: "Generate token" 34 | id: generate_token 35 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a 36 | with: 37 | app_id: ${{ env.AWS_DS_TOKEN_CREATOR_ID }} 38 | private_key: ${{ env.AWS_DS_TOKEN_CREATOR_PEM }} 39 | - name: Run Commands 40 | uses: ./actions/commands 41 | with: 42 | token: ${{ steps.generate_token.outputs.token }} 43 | configPath: issue_commands 44 | -------------------------------------------------------------------------------- /.github/workflows/pr-commands.yml: -------------------------------------------------------------------------------- 1 | name: PR automation 2 | on: 3 | pull_request_target: # zizmor: ignore[dangerous-triggers] 4 | types: 5 | - labeled 6 | - opened 7 | concurrency: 8 | group: pr-commands-${{ github.event.number }} 9 | permissions: {} 10 | 11 | jobs: 12 | main: 13 | permissions: 14 | contents: read 15 | pull-requests: write 16 | id-token: write # The "id-token: write" permission is required by "get-vault-secrets" action 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Actions 20 | uses: actions/checkout@v4 21 | with: 22 | repository: "grafana/grafana-github-actions" 23 | path: ./actions 24 | ref: main 25 | persist-credentials: false 26 | - name: Install Actions 27 | run: npm install --production --prefix ./actions 28 | - name: Get secrets from vault 29 | id: get-secrets 30 | uses: grafana/shared-workflows/actions/get-vault-secrets@main 31 | with: 32 | repo_secrets: | 33 | AWS_DS_TOKEN_CREATOR_ID=aws-ds-token-creator:app_id 34 | AWS_DS_TOKEN_CREATOR_PEM=aws-ds-token-creator:pem 35 | - name: "Generate token" 36 | id: generate_token 37 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a 38 | with: 39 | app_id: ${{ env.AWS_DS_TOKEN_CREATOR_ID }} 40 | private_key: ${{ env.AWS_DS_TOKEN_CREATOR_PEM }} 41 | - name: Run Commands 42 | uses: ./actions/commands 43 | with: 44 | token: ${{ steps.generate_token.outputs.token }} 45 | configPath: pr-commands 46 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | # run at 1:30 every day 5 | - cron: "30 1 * * *" 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | # start from the oldest issues when performing stale operations 18 | ascending: true 19 | days-before-issue-stale: 365 20 | days-before-issue-close: 30 21 | stale-issue-label: stale 22 | exempt-issue-labels: no stalebot,type/epic 23 | stale-issue-message: > 24 | This issue has been automatically marked as stale because it has not had 25 | activity in the last year. It will be closed in 30 days if no further activity occurs. Please 26 | feel free to leave a comment if you believe the issue is still relevant. 27 | Thank you for your contributions! 28 | close-issue-message: > 29 | This issue has been automatically closed because it has not had any further 30 | activity in the last 30 days. Thank you for your contributions! 31 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | unpinned-uses: 3 | config: 4 | policies: 5 | actions/*: any 6 | github/*: any 7 | grafana/*: any -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | ci/ 4 | cypress/report.json 5 | cypress/screenshots/actual 6 | cypress/videos/ 7 | dist/ 8 | yarn-error.log 9 | .idea/ 10 | .vscode/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.38.6 6 | 7 | - Fix: use configured externalID when not using Grafana Assume Role by @njvrzm in [#248](https://github.com/grafana/grafana-aws-sdk/pull/248) 8 | 9 | ## 0.38.5 10 | 11 | - Fix externalID handling by @njvrzm in [#246](https://github.com/grafana/grafana-aws-sdk/pull/246) 12 | - Add CloudWatch AWS/EKS metrics and dimensions by @jangaraj in [#242](https://github.com/grafana/grafana-aws-sdk/pull/242) 13 | - Bump github.com/grafana/grafana-plugin-sdk-go by @dependabot in [#241](https://github.com/grafana/grafana-aws-sdk/pull/241) 14 | 15 | ## 0.38.4 16 | 17 | - Add AvailableMemory metric for the DMS service by @andriikushch in [#244](https://github.com/grafana/grafana-aws-sdk/pull/244) 18 | - Github actions: Add token write permission by @idastambuk in [#243](https://github.com/grafana/grafana-aws-sdk/pull/243) 19 | - Fix: Workspace IAM Role auth method does not need special handling by @njvrzm in [#245](https://github.com/grafana/grafana-aws-sdk/pull/245) 20 | 21 | ## 0.38.3 22 | 23 | - Chore: add zizmore ignore rule in [#239](https://github.com/grafana/grafana-aws-sdk/pull/239) 24 | - Bugfix: Sigv4: Use externalId when signing with sigv4 in [#238](https://github.com/grafana/grafana-aws-sdk/pull/238) 25 | - Bump the all-go-dependencies with 9 updates, update go lint and vulnerability check image tags in [#232](https://github.com/grafana/grafana-aws-sdk/pull/232) 26 | - Bump golang.org/x/net from 0.34.0 to 0.36.0 in the go_modules group in [#210](https://github.com/grafana/grafana-aws-sdk/pull/210) 27 | 28 | ## 0.38.2 29 | 30 | - Support passing session token with v2 auth by @njvrzm in [#234](https://github.com/grafana/grafana-aws-sdk/pull/234) 31 | - Add vault tokens and zizmor config by @katebrenner in [#236](https://github.com/grafana/grafana-aws-sdk/pull/236) 32 | - Update network firewall metrics by @tristanburgess in [#237](https://github.com/grafana/grafana-aws-sdk/pull/237) 33 | 34 | ## 0.38.1 35 | 36 | - Cleanup github actions files by @iwysiu in [#233](https://github.com/grafana/grafana-aws-sdk/pull/233) 37 | - Fix check for multitenant temporary credentials by @iwysiu in [#235](https://github.com/grafana/grafana-aws-sdk/pull/235) 38 | 39 | ## 0.38.0 40 | 41 | - Add support for multitenant temporary credentials to v1 path by @iwysiu in [#231](https://github.com/grafana/grafana-aws-sdk/pull/231) 42 | 43 | ## 0.37.0 44 | 45 | - Fix: clone default transport instead of using it for PDC by @njvrzm in [#229](https://github.com/grafana/grafana-aws-sdk/pull/229) 46 | - Fix paths for multitenant [#228](https://github.com/grafana/grafana-aws-sdk/pull/228) 47 | 48 | ## 0.36.0 49 | 50 | - Add dimensions to msk connect and pipe metric namespaces by @rrhodes in [#223](https://github.com/grafana/grafana-aws-sdk/pull/223) 51 | - Fix: Use DefaultClient in awsauth if given nil HTTPClient by @njvrzm in [#226](https://github.com/grafana/grafana-aws-sdk/pull/226) 52 | 53 | ## 0.35.0 54 | 55 | - Update Namespace Metrics and Dimensions tests, add missing dimensions by @rrhodes in https://github.com/grafana/grafana-aws-sdk/pull/218 56 | - Add DBLoadRelativeToNumVCPUs metric to RDS by @tristanburgess in https://github.com/grafana/grafana-aws-sdk/pull/219 57 | - Add support for multi tenant temporary credentials by @iwysiu in https://github.com/grafana/grafana-aws-sdk/pull/213 58 | 59 | ## 0.34.0 60 | 61 | - feat: Add metrics for lambda event source mappings by @rrhodes in https://github.com/grafana/grafana-aws-sdk/pull/216 62 | - Enable dataproxy.row_limit configuration option from Grafana by @kevinwcyu in https://github.com/grafana/grafana-aws-sdk/pull/215 63 | 64 | ## 0.33.1 65 | 66 | - Fix: use alternate STS endpoint for STS interaction if given by @njvrzm in https://github.com/grafana/grafana-aws-sdk/pull/214 67 | 68 | ## 0.33.0 69 | 70 | - Update CodeBuild metrics and dimensions by @hectorruiz-it in https://github.com/grafana/grafana-aws-sdk/pull/209 71 | - Add support for aws-sdk-go-v2 authentication by @njvrzm in https://github.com/grafana/grafana-aws-sdk/pull/202 72 | 73 | ## 0.32.0 74 | 75 | - AWSDS: Add QueryExecutionError type 76 | 77 | ## 0.31.8 78 | 79 | - Bump github.com/grafana/grafana-plugin-sdk-go from 0.265.0 to 0.266.0 in the all-go-dependencies group by @dependabot in https://github.com/grafana/grafana-aws-sdk/pull/204 80 | - Bump the all-go-dependencies group across 1 directory with 2 updates by @dependabot in https://github.com/grafana/grafana-aws-sdk/pull/201 81 | - Add missing LegacyModelInvocations AWS bedrock metric by @drmdrew in https://github.com/grafana/grafana-aws-sdk/pull/200 82 | - Chore: add label to external contributions by @kevinwcyu in https://github.com/grafana/grafana-aws-sdk/pull/198 83 | - Update CloudWatch AWS/EBS metrics and dimensions by @idastambuk in https://github.com/grafana/grafana-aws-sdk/pull/197 84 | - Bump the all-go-dependencies group with 3 updates by @dependabot in https://github.com/grafana/grafana-aws-sdk/pull/194 85 | 86 | ## 0.31.7 87 | 88 | - Bump the all-go-dependencies group across 1 directory with 4 updates by @dependabot in https://github.com/grafana/grafana-aws-sdk/pull/190 89 | - Bump the all-go-dependencies group across 1 directory with 3 updates by @dependabot in https://github.com/grafana/grafana-aws-sdk/pull/193 90 | 91 | ## 0.31.6 92 | 93 | - Add new SQS FIFO metrics by @thepalbi in https://github.com/grafana/grafana-aws-sdk/pull/187 94 | - Add aws-sdk-go-v2 credentials provider (session wrapper) by @njvrzm in https://github.com/grafana/grafana-aws-sdk/pull/185 95 | 96 | ## 0.31.5 97 | 98 | - Update dependencies in https://github.com/grafana/grafana-aws-sdk/pull/176 99 | - actions/checkout from 2 to 4 100 | - tibdex/github-app-token from 1.8.0 to 2.1.0 101 | - Update github.com/grafana/sqlds/v4 from 4.1.2 to 4.1.3 in https://github.com/grafana/grafana-aws-sdk/pull/178 102 | - Remove ReadAuthSettings deprecation warning in https://github.com/grafana/grafana-aws-sdk/pull/184 103 | - Add metrics for elasticache serverless in https://github.com/grafana/grafana-aws-sdk/pull/183 104 | - Update AWS/AmplifyHosting metrics in https://github.com/grafana/grafana-aws-sdk/pull/186 105 | 106 | ## 0.31.4 107 | 108 | - Update dependencies in https://github.com/grafana/grafana-aws-sdk/pull/175 109 | - github.com/aws/aws-sdk-go from v1.51.31 to v1.55.5 110 | - github.com/grafana/grafana-plugin-sdk-go from v0.250.0 to v0.258.0 111 | - github.com/grafana/sqlds/v4 from v4.1.0 to v4.1.2 112 | - Update AWS/SES metrics and dimensions in https://github.com/grafana/grafana-aws-sdk/pull/174 113 | 114 | ## 0.31.3 115 | 116 | - Update CloudWatch Metrics for AWS IoT SiteWise in https://github.com/grafana/grafana-aws-sdk/pull/172 117 | 118 | ## 0.31.2 119 | 120 | - Upgrade grafana-plugin-sdk-go to v0.250.0 in https://github.com/grafana/grafana-aws-sdk/pull/170 121 | 122 | ## 0.31.1 123 | 124 | - Mark dowstream errors in sessions.go in https://github.com/grafana/grafana-aws-sdk/pull/169 125 | 126 | ## 0.31.0 127 | 128 | - Update sqlds to v4.1.0 in https://github.com/grafana/grafana-aws-sdk/pull/166 129 | - Add AmazonMWAA and missing Aurora RDS Metrics in https://github.com/grafana/grafana-aws-sdk/pull/165 130 | - Add more metrics to the services in https://github.com/grafana/grafana-aws-sdk/pull/161 131 | 132 | ## 0.30.0 133 | 134 | - Sort NamespaceMetricsMap by @andriikushch in https://github.com/grafana/grafana-aws-sdk/pull/156 135 | - Add expected casing for AWS/Kafka TCPConnections by @kgeckhart in https://github.com/grafana/grafana-aws-sdk/pull/158 136 | - Move AWS/DataLifeCycleManager metrics to AWS/EBS by @iwysiu in https://github.com/grafana/grafana-aws-sdk/pull/159 137 | 138 | ## 0.29.0 139 | 140 | - Support errorsource by @njvrzm in https://github.com/grafana/grafana-aws-sdk/pull/155 141 | - Add DatabaseCapacityUsageCountedForEvictPercentage for AWS/ElastiCache by @andriikushch in https://github.com/grafana/grafana-aws-sdk/pull/152 142 | - Add some missing metrics to AWS/ElastiCache by @andriikushch in https://github.com/grafana/grafana-aws-sdk/pull/153 143 | 144 | ## 0.28.0 145 | 146 | - Add SigV4MiddlewareWithAuthSettings and deprecate SigV4Middleware [#150](https://github.com/grafana/grafana-aws-sdk/pull/150) 147 | 148 | [Breaking Change] `sigv4.New` now expects the auth settings to be passed in instead of fetched from environment variables. 149 | 150 | ## 0.27.1 151 | 152 | - add case sensitive metric name millisBehindLatest for KinesisAnalytics by @tristanburgess in https://github.com/grafana/grafana-aws-sdk/pull/148 153 | 154 | ## v0.27.0 155 | 156 | - Add GetSessionWithAuthSettings and Deprecate GetSession [#144](https://github.com/grafana/grafana-aws-sdk/pull/144) 157 | 158 | ## v0.26.1 159 | 160 | - Add CloudWatch Metrics and Dimension Key maps by @iwysiu in [#142](https://github.com/grafana/grafana-aws-sdk/pull/142) 161 | 162 | ## v0.26.0 163 | 164 | - **breaking**: Add more context handling @njvrzm in [#139](https://github.com/grafana/grafana-aws-sdk/pull/139) 165 | - upgrade all deps by @tristanburgess in [#134](https://github.com/grafana/grafana-aws-sdk/pull/134) 166 | - Cleanup: typos, unused methods & parameters, docstrings, etc. by @njvrzm in [#138](https://github.com/grafana/grafana-aws-sdk/pull/138) 167 | 168 | ## v0.25.1 169 | 170 | - Fix: aws sts assume role with custom endpoint in [#136](https://github.com/grafana/grafana-aws-sdk/pull/136) 171 | 172 | ## v0.25.0 173 | 174 | - Add SigV4 middleware from Grafana core. 175 | 176 | ## v0.24.0 177 | 178 | - Sessions: Use STS regional endpoint in assume role for opt-in regions in [#129](https://github.com/grafana/grafana-aws-sdk/pull/129) 179 | - Add health check for async queries in [#124](https://github.com/grafana/grafana-aws-sdk/pull/125) 180 | 181 | ## v0.23.1 182 | 183 | -Fix warning for getting GF_SECURE_SOCKS_DATASOURCE_PROXY_SERVER_ENABLED env variable [#125](https://github.com/grafana/grafana-aws-sdk/pull/125) 184 | 185 | ## v0.23.0 186 | 187 | - Deprecate using environment variables for auth settings in sessions [#121](https://github.com/grafana/grafana-aws-sdk/pull/121) 188 | 189 | ## v0.22.0 190 | 191 | - Add ReadAuthSettings to get config settings from context [#118](https://github.com/grafana/grafana-aws-sdk/pull/118) 192 | 193 | ## v0.21.0 194 | 195 | - Update grafana-plugin-sdk-go to v0.201.0 196 | - Update sqlds to v3.2.0 197 | 198 | ## v0.20.0 199 | 200 | - Add ca-west-1 to list of opt-in regions @zspeaks [#111](https://github.com/grafana/grafana-aws-sdk/pull/111) 201 | 202 | ## v0.19.3 203 | 204 | - Fix assuming a role with an endpoint set 205 | - Include invalid authType in error message 206 | - Update go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace from 0.37.0 to 0.44.0 207 | 208 | ## v0.19.2 209 | 210 | - Update grafana-plugin-sdk-go from v0.134.0 to v0.172.0 211 | - Update go from 1.17 to 1.20 212 | - Add AMAZON_MANAGED_GRAFANA to the UserAgent string header 213 | 214 | ## v0.19.1 215 | 216 | - Update aws-sdk from v1.44.9 to v1.44.323 217 | 218 | ## v0.19.0 219 | 220 | - Add `il-central-1` to opt-in region list 221 | 222 | ## v0.18.0 223 | 224 | - Add Support for Temporary Credentials in Grafana Cloud @idastambuk @sarahzinger [84](https://github.com/grafana/grafana-aws-sdk/pull/84) 225 | - Add Contributing.md file 226 | 227 | ## v0.17.0 228 | 229 | - Add GetDatasourceLastUpdatedTime util for client caching @iwysiu in [#90](https://github.com/grafana/grafana-aws-sdk/pull/90) 230 | 231 | ## v0.16.1 232 | 233 | - ShouldCacheQuery should handle nil responses @iwysiu in [#87](https://github.com/grafana/grafana-aws-sdk/pull/87) 234 | 235 | ## v0.16.0 236 | 237 | - Add ShouldCacheQuery util for async caching @iwysiu in [#85](https://github.com/grafana/grafana-aws-sdk/pull/85) 238 | 239 | ## v0.15.1 240 | 241 | - Fix expressions with async datasource @iwysiu in [#83](https://github.com/grafana/grafana-aws-sdk/pull/83) 242 | 243 | ## v0.15.0 244 | 245 | Updating opt-in regions list by @eunice98k in https://github.com/grafana/grafana-aws-sdk/pull/80 246 | 247 | ## v0.13.0 248 | 249 | - Fix connections for multiple async datasources @iwysiu in [#73](https://github.com/grafana/grafana-aws-sdk/pull/73) 250 | - Pass query args to GetAsyncDB @kevinwcyu in [#71](https://github.com/grafana/grafana-aws-sdk/pull/71) 251 | 252 | ## v0.12.0 253 | 254 | Updating opt-in regions list by @robbierolin in https://github.com/grafana/grafana-aws-sdk/pull/66 255 | 256 | ## v0.11.0 257 | 258 | Switch ec2 role cred provider to remote cred provider https://github.com/grafana/grafana-aws-sdk/pull/62 259 | 260 | ## v0.9.0 261 | 262 | [Breaking Change] Refactor `GetSession` method to allow adding the data source config and the user agent to configure the default HTTP client. 263 | 264 | ## v0.8.0 265 | 266 | Added interfaces and functions for SQL data sources 267 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Local Dev 2 | 3 | 1. Navigate to whatever is consuming grafana-aws-sdk (ex: Grafana and/or an aws data source plugin) 4 | 2. In that repo find the go.mod file and add a replace line above the require line and point to the code path of your local copy of the repo: `replace github.com/grafana/grafana-aws-sdk => /Users/yourname/local/path/to/grafana-aws-sdk` 5 | 6 | 3. No additional build step is necessary, whatever consumes this repo will build it for you. 7 | 8 | # Releasing: 9 | 10 | 1. Make a pr to update changelog with your changes and merge the changes to main 11 | 1. Navigate to https://github.com/grafana/grafana-aws-sdk/releases 12 | 1. Click the "Draft a new release" button 13 | 1. Click the "Choose a tag" dropdown and type in the name of the release you want (if the tag doesn't exist yet it will be created from whatever the target next to it is, in this case the default is main) 14 | 1. Type in a release title and description 15 | 1. Click Publish release 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/magefile/mage/sh" 8 | ) 9 | 10 | // Build builds the binaries. 11 | func Build() error { 12 | return sh.RunV("go", "build", "./...") 13 | } 14 | 15 | // Test runs the test suite. 16 | func Test() error { 17 | return sh.RunV("go", "test", "./...") 18 | } 19 | 20 | func Lint() error { 21 | if err := sh.RunV("golangci-lint", "run", "./..."); err != nil { 22 | return err 23 | } 24 | return nil 25 | } 26 | 27 | // Drone signs the Drone configuration file 28 | // This needs to be run everytime the drone.yml file is modified 29 | // See https://github.com/grafana/deployment_tools/blob/master/docs/infrastructure/drone/signing.md for more info 30 | func Drone() error { 31 | if err := sh.RunV("drone", "lint"); err != nil { 32 | return err 33 | } 34 | 35 | if err := sh.RunV("drone", "--server", "https://drone.grafana.net", "sign", "--save", "grafana/grafana-aws-sdk"); err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | var Default = Build 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana AWS SDK 2 | 3 | This is a common package that can be used for all amazon plugins. 4 | 5 | ## Backend plugins (go sdk) 6 | 7 | see the ./pkg folder 8 | 9 | ## Frontend configuration 10 | 11 | Frontend code has been moved to https://github.com/grafana/grafana-aws-sdk-react 12 | 13 | ## Drone configuration 14 | 15 | Drone signs the Drone configuration file. This needs to be run every time the drone.yml file is modified. See https://github.com/grafana/deployment_tools/blob/master/docs/infrastructure/drone/signing.md for more info. 16 | 17 | ### Update drone build 18 | 19 | If you have not installed drone CLI follow [these instructions](https://docs.drone.io/cli/install/) 20 | 21 | To sign the `.drone.yml` file: 22 | 23 | ```bash 24 | # Get your drone token from https://drone.grafana.net/account 25 | export DRONE_TOKEN= 26 | 27 | mage drone 28 | ``` 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/grafana-aws-sdk 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go v1.55.7 9 | github.com/aws/aws-sdk-go-v2 v1.36.3 10 | github.com/aws/aws-sdk-go-v2/config v1.29.14 11 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 12 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 13 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 14 | github.com/aws/smithy-go v1.22.3 15 | github.com/google/go-cmp v0.7.0 16 | github.com/grafana/grafana-plugin-sdk-go v0.277.1 17 | github.com/grafana/sqlds/v4 v4.2.2 18 | github.com/jpillora/backoff v1.0.0 19 | github.com/magefile/mage v1.15.0 20 | github.com/stretchr/testify v1.10.0 21 | ) 22 | 23 | require ( 24 | github.com/BurntSushi/toml v1.4.0 // indirect 25 | github.com/apache/arrow-go/v18 v18.2.0 // indirect 26 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 27 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 28 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 33 | github.com/beorn7/perks v1.0.1 // indirect 34 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 35 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 36 | github.com/cheekybits/genny v1.0.0 // indirect 37 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect 38 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 39 | github.com/davecgh/go-spew v1.1.1 // indirect 40 | github.com/elazarl/goproxy v1.7.2 // indirect 41 | github.com/fatih/color v1.17.0 // indirect 42 | github.com/getkin/kin-openapi v0.132.0 // indirect 43 | github.com/go-logr/logr v1.4.2 // indirect 44 | github.com/go-logr/stdr v1.2.2 // indirect 45 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 46 | github.com/go-openapi/swag v0.23.0 // indirect 47 | github.com/goccy/go-json v0.10.5 // indirect 48 | github.com/gogo/protobuf v1.3.2 // indirect 49 | github.com/golang/protobuf v1.5.4 // indirect 50 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 51 | github.com/google/uuid v1.6.0 // indirect 52 | github.com/gorilla/mux v1.8.1 // indirect 53 | github.com/grafana/dataplane/sdata v0.0.9 // indirect 54 | github.com/grafana/otel-profiling-go v0.5.1 // indirect 55 | github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect 56 | github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect 57 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect 58 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect 59 | github.com/hashicorp/go-hclog v1.6.3 // indirect 60 | github.com/hashicorp/go-plugin v1.6.3 // indirect 61 | github.com/hashicorp/yamux v0.1.1 // indirect 62 | github.com/jmespath/go-jmespath v0.4.0 // indirect 63 | github.com/josharian/intern v1.0.0 // indirect 64 | github.com/json-iterator/go v1.1.12 // indirect 65 | github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect 66 | github.com/klauspost/compress v1.18.0 // indirect 67 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 68 | github.com/mailru/easyjson v0.7.7 // indirect 69 | github.com/mattetti/filebuffer v1.0.1 // indirect 70 | github.com/mattn/go-colorable v0.1.13 // indirect 71 | github.com/mattn/go-isatty v0.0.20 // indirect 72 | github.com/mattn/go-runewidth v0.0.16 // indirect 73 | github.com/mitchellh/go-homedir v1.1.0 // indirect 74 | github.com/mithrandie/csvq v1.18.1 // indirect 75 | github.com/mithrandie/csvq-driver v1.7.0 // indirect 76 | github.com/mithrandie/go-file/v2 v2.1.0 // indirect 77 | github.com/mithrandie/go-text v1.6.0 // indirect 78 | github.com/mithrandie/ternary v1.1.1 // indirect 79 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 80 | github.com/modern-go/reflect2 v1.0.2 // indirect 81 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 82 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 83 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 84 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 85 | github.com/oklog/run v1.1.0 // indirect 86 | github.com/olekukonko/tablewriter v0.0.5 // indirect 87 | github.com/perimeterx/marshmallow v1.1.5 // indirect 88 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 89 | github.com/pmezard/go-difflib v1.0.0 // indirect 90 | github.com/prometheus/client_golang v1.22.0 // indirect 91 | github.com/prometheus/client_model v0.6.1 // indirect 92 | github.com/prometheus/common v0.63.0 // indirect 93 | github.com/prometheus/procfs v0.15.1 // indirect 94 | github.com/rivo/uniseg v0.4.7 // indirect 95 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 96 | github.com/stretchr/objx v0.5.2 // indirect 97 | github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect 98 | github.com/unknwon/com v1.0.1 // indirect 99 | github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect 100 | github.com/urfave/cli v1.22.16 // indirect 101 | github.com/zeebo/xxh3 v1.0.2 // indirect 102 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 103 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 104 | go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect 105 | go.opentelemetry.io/contrib/propagators/jaeger v1.35.0 // indirect 106 | go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 // indirect 107 | go.opentelemetry.io/otel v1.35.0 // indirect 108 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 109 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 110 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 111 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 112 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 113 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 114 | golang.org/x/crypto v0.37.0 // indirect 115 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 116 | golang.org/x/mod v0.23.0 // indirect 117 | golang.org/x/net v0.39.0 // indirect 118 | golang.org/x/sync v0.13.0 // indirect 119 | golang.org/x/sys v0.32.0 // indirect 120 | golang.org/x/term v0.31.0 // indirect 121 | golang.org/x/text v0.24.0 // indirect 122 | golang.org/x/tools v0.30.0 // indirect 123 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 124 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 125 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 126 | google.golang.org/grpc v1.71.1 // indirect 127 | google.golang.org/protobuf v1.36.6 // indirect 128 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 129 | gopkg.in/yaml.v3 v3.0.1 // indirect 130 | ) 131 | -------------------------------------------------------------------------------- /pkg/awsauth/api_client.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/config" 7 | "github.com/aws/aws-sdk-go-v2/credentials" 8 | "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" 9 | "github.com/aws/aws-sdk-go-v2/credentials/stscreds" 10 | "github.com/aws/aws-sdk-go-v2/service/sts" 11 | ) 12 | 13 | type LoadOptionsFunc = func(*config.LoadOptions) error 14 | 15 | // AWSAPIClient isolates most of our interactions with the AWS SDK to make it easier to mock in tests 16 | type AWSAPIClient interface { 17 | LoadDefaultConfig(ctx context.Context, options ...LoadOptionsFunc) (aws.Config, error) 18 | NewStaticCredentialsProvider(key, secret, session string) aws.CredentialsProvider 19 | NewSTSClientFromConfig(cfg aws.Config) stscreds.AssumeRoleAPIClient 20 | NewAssumeRoleProvider(client stscreds.AssumeRoleAPIClient, roleARN string, optFns ...func(*stscreds.AssumeRoleOptions)) aws.CredentialsProvider 21 | NewCredentialsCache(provider aws.CredentialsProvider, optFns ...func(options *aws.CredentialsCacheOptions)) aws.CredentialsProvider 22 | NewEC2RoleCreds() aws.CredentialsProvider 23 | } 24 | 25 | type awsAPIClient struct{} 26 | 27 | func (c awsAPIClient) NewStaticCredentialsProvider(key, secret, session string) aws.CredentialsProvider { 28 | return credentials.NewStaticCredentialsProvider(key, secret, session) 29 | } 30 | func (c awsAPIClient) LoadDefaultConfig(ctx context.Context, options ...LoadOptionsFunc) (aws.Config, error) { 31 | return config.LoadDefaultConfig(ctx, options...) 32 | } 33 | func (c awsAPIClient) NewSTSClientFromConfig(cfg aws.Config) stscreds.AssumeRoleAPIClient { 34 | return sts.NewFromConfig(cfg) 35 | } 36 | 37 | func (c awsAPIClient) NewAssumeRoleProvider(client stscreds.AssumeRoleAPIClient, roleARN string, optFns ...func(*stscreds.AssumeRoleOptions)) aws.CredentialsProvider { 38 | return stscreds.NewAssumeRoleProvider(client, roleARN, optFns...) 39 | } 40 | 41 | func (c awsAPIClient) NewCredentialsCache(provider aws.CredentialsProvider, optFns ...func(options *aws.CredentialsCacheOptions)) aws.CredentialsProvider { 42 | return aws.NewCredentialsCache(provider, optFns...) 43 | } 44 | func (c awsAPIClient) NewEC2RoleCreds() aws.CredentialsProvider { 45 | return ec2rolecreds.New() 46 | } 47 | -------------------------------------------------------------------------------- /pkg/awsauth/auth.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | "strings" 11 | ) 12 | 13 | type ConfigProvider interface { 14 | GetConfig(context.Context, Settings) (aws.Config, error) 15 | } 16 | 17 | func NewConfigProvider() ConfigProvider { 18 | return newAWSConfigProviderWithClient(awsAPIClient{}) 19 | } 20 | 21 | func newAWSConfigProviderWithClient(client AWSAPIClient) *awsConfigProvider { 22 | return &awsConfigProvider{client, make(map[uint64]aws.Config)} 23 | } 24 | 25 | type awsConfigProvider struct { 26 | client AWSAPIClient 27 | cache map[uint64]aws.Config 28 | } 29 | 30 | func (rcp *awsConfigProvider) GetConfig(ctx context.Context, authSettings Settings) (aws.Config, error) { 31 | logger := backend.Logger.FromContext(ctx) 32 | 33 | key := authSettings.Hash() 34 | cached, exists := rcp.cache[key] 35 | if exists { 36 | logger.Debug("returning config from cache") 37 | return cached, nil 38 | } 39 | logger.Debug("creating new config") 40 | 41 | options := authSettings.BaseOptions() 42 | 43 | authType := authSettings.GetAuthType() 44 | logger.Debug(fmt.Sprintf("Using auth type: %s", authType)) 45 | switch authType { 46 | case AuthTypeDefault, AuthTypeEC2IAMRole: // nothing else to do here 47 | case AuthTypeKeys: 48 | options = append(options, authSettings.WithStaticCredentials(rcp.client)) 49 | case AuthTypeSharedCreds: 50 | options = append(options, authSettings.WithSharedCredentials()) 51 | case AuthTypeGrafanaAssumeRole: 52 | settings, _ := awsds.ReadAuthSettingsFromContext(ctx) 53 | authSettings.ExternalID = settings.ExternalID 54 | options = append(options, authSettings.WithGrafanaAssumeRole(ctx, rcp.client)) 55 | default: 56 | return aws.Config{}, fmt.Errorf("unknown auth type: %s", authType) 57 | } 58 | 59 | cfg, err := rcp.client.LoadDefaultConfig(ctx, options...) 60 | if err != nil { 61 | return aws.Config{}, err 62 | } 63 | 64 | if authSettings.AssumeRoleARN != "" { 65 | options = append(authSettings.BaseOptions(), authSettings.WithAssumeRole(cfg, rcp.client)) 66 | cfg, err = rcp.client.LoadDefaultConfig(ctx, options...) 67 | if err != nil { 68 | return aws.Config{}, err 69 | } 70 | } 71 | 72 | rcp.cache[key] = cfg 73 | return cfg, nil 74 | } 75 | 76 | func isStsEndpoint(ep *string) bool { 77 | return ep != nil && (strings.HasPrefix(*ep, "sts.") || strings.HasPrefix(*ep, "sts-fips.")) 78 | } 79 | -------------------------------------------------------------------------------- /pkg/awsauth/auth_test.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" 8 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "os" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | type testSuite []testCase 18 | 19 | func (ts testSuite) runAll(t *testing.T) { 20 | for _, tc := range ts { 21 | t.Run(tc.name, tc.Run) 22 | } 23 | } 24 | 25 | type testCase struct { 26 | name string 27 | shouldError bool 28 | authSettings Settings 29 | assumedCredentials *ststypes.Credentials 30 | assumeRoleShouldFail bool 31 | environment map[string]string 32 | } 33 | 34 | const StackID = "42" 35 | 36 | func (tc testCase) Run(t *testing.T) { 37 | ctx := backend.WithGrafanaConfig(context.Background(), 38 | backend.NewGrafanaCfg(map[string]string{awsds.GrafanaAssumeRoleExternalIdKeyName: StackID})) 39 | client := &mockAWSAPIClient{&mockAssumeRoleAPIClient{}} 40 | 41 | if tc.authSettings.AssumeRoleARN != "" { 42 | client.assumeRoleClient.On("AssumeRole").Return(tc.assumeRoleShouldFail, tc.assumedCredentials) 43 | } 44 | provider := newAWSConfigProviderWithClient(client) 45 | defer setUpAndRestoreEnvironment(tc.environment)() // a little goofy-looking but it works 46 | 47 | cfg, err := provider.GetConfig(ctx, tc.authSettings) 48 | 49 | if tc.shouldError { 50 | require.Error(t, err) 51 | } else { 52 | require.NoError(t, err) 53 | creds, err := cfg.Credentials.Retrieve(ctx) 54 | if tc.assumeRoleShouldFail { 55 | require.Error(t, err) 56 | } else { 57 | require.NoError(t, err) 58 | tc.assertConfig(t, cfg) 59 | if tc.authSettings.GetAuthType() == AuthTypeKeys && tc.authSettings.SessionToken != "" { 60 | assert.Equal(t, tc.authSettings.SessionToken, creds.SessionToken) 61 | } 62 | if tc.authSettings.GetAuthType() == AuthTypeGrafanaAssumeRole { 63 | assert.Equal(t, client.assumeRoleClient.calledExternalId, StackID) 64 | } else if tc.authSettings.AssumeRoleARN != "" && tc.authSettings.ExternalID != "" { 65 | assert.Equal(t, client.assumeRoleClient.calledExternalId, tc.authSettings.ExternalID) 66 | } 67 | accessKey, secret := tc.getExpectedKeyAndSecret(t) 68 | assert.Equal(t, accessKey, creds.AccessKeyID) 69 | assert.Equal(t, secret, creds.SecretAccessKey) 70 | } 71 | } 72 | if isStsEndpoint(&tc.authSettings.Endpoint) { 73 | assert.Equal(t, tc.authSettings.Endpoint, *client.assumeRoleClient.stsConfig.BaseEndpoint) 74 | assert.Nil(t, cfg.BaseEndpoint) 75 | } 76 | } 77 | 78 | func (tc testCase) assertConfig(t *testing.T, cfg aws.Config) { 79 | if tc.authSettings.GetAuthType() == AuthTypeDefault && tc.environment["AWS_REGION"] != "" { 80 | assert.Equal(t, tc.environment["AWS_REGION"], cfg.Region) 81 | } else { 82 | assert.Equal(t, tc.authSettings.Region, cfg.Region) 83 | } 84 | } 85 | 86 | func (tc testCase) getExpectedKeyAndSecret(t *testing.T) (string, string) { 87 | if tc.assumedCredentials != nil { 88 | return *tc.assumedCredentials.AccessKeyId, *tc.assumedCredentials.SecretAccessKey 89 | } 90 | switch tc.authSettings.GetAuthType() { 91 | case AuthTypeKeys: 92 | return tc.authSettings.AccessKey, tc.authSettings.SecretKey 93 | case AuthTypeSharedCreds: 94 | // from testdata/shared_credentials 95 | return "AFAKEONEYESGOOD", "zippitydoodah" 96 | case AuthTypeGrafanaAssumeRole: 97 | // from testdata/assume_role_credentials 98 | return "ADIFFERENTONENICE", "merrilywerollalong" 99 | case AuthTypeDefault: 100 | if tc.environment["AWS_ACCESS_KEY_ID"] != "" { 101 | return tc.environment["AWS_ACCESS_KEY_ID"], tc.environment["AWS_SECRET_ACCESS_KEY"] 102 | } else { 103 | // from testdata/credentials 104 | return "AREGULAROLDKEY", "askmenosecrets" 105 | } 106 | case AuthTypeEC2IAMRole: 107 | t.Error("This test type is not yet implemented") 108 | return "", "" 109 | default: 110 | t.Errorf("Unsupported auth type: %s", tc.authSettings.GetAuthType()) 111 | return "", "" 112 | } 113 | } 114 | 115 | // setUpAndRestoreEnvironment sets the given environment variables and 116 | // case and returns a function that restores them to their original value. 117 | // Use like: 118 | // 119 | // defer setUpAndRestoreEnvironment(env)() 120 | func setUpAndRestoreEnvironment(env map[string]string) func() { 121 | origEnv := map[string]string{} 122 | for k, v := range env { 123 | origEnv[k] = os.Getenv(k) 124 | _ = os.Setenv(k, v) 125 | } 126 | return func() { 127 | for k, v := range origEnv { 128 | _ = os.Setenv(k, v) 129 | } 130 | } 131 | } 132 | 133 | func testDataPath(fn string) string { 134 | here, _ := os.Getwd() 135 | return fmt.Sprintf("%s/testdata/%s", here, fn) 136 | } 137 | 138 | func TestGetAWSConfig_Keys(t *testing.T) { 139 | testSuite{ 140 | { 141 | name: "static credentials", 142 | authSettings: Settings{ 143 | AuthType: AuthTypeKeys, 144 | AccessKey: "tensile", 145 | SecretKey: "diaphanous", 146 | Region: "eu-north-1", 147 | }, 148 | }, 149 | { 150 | name: "static credentials, legacy auth type", 151 | authSettings: Settings{ 152 | LegacyAuthType: awsds.AuthTypeKeys, 153 | AccessKey: "ubiquitous", 154 | SecretKey: "malevolent", 155 | Region: "ap-south-1", 156 | }, 157 | }, 158 | { 159 | name: "static credentials, sts endpoint", 160 | authSettings: Settings{ 161 | LegacyAuthType: awsds.AuthTypeKeys, 162 | AccessKey: "ubiquitous", 163 | SecretKey: "malevolent", 164 | Region: "ap-south-1", 165 | }, 166 | }, 167 | { 168 | name: "static credentials with session token", 169 | authSettings: Settings{ 170 | LegacyAuthType: awsds.AuthTypeKeys, 171 | AccessKey: "ubiquitous", 172 | SecretKey: "malevolent", 173 | Region: "ap-south-1", 174 | SessionToken: "alphabet", 175 | }, 176 | }, 177 | }.runAll(t) 178 | } 179 | 180 | func TestGetAWSConfig_Keys_AssumeRule(t *testing.T) { 181 | testSuite{ 182 | { 183 | name: "static assume role with success", 184 | authSettings: Settings{ 185 | AuthType: AuthTypeKeys, 186 | AccessKey: "tensile", 187 | SecretKey: "diaphanous", 188 | Region: "eu-north-1", 189 | AssumeRoleARN: "arn:aws:iam::1234567890:role/aws-service-role", 190 | }, 191 | assumedCredentials: &ststypes.Credentials{ 192 | AccessKeyId: aws.String("assumed"), 193 | SecretAccessKey: aws.String("role"), 194 | SessionToken: aws.String("session"), 195 | Expiration: aws.Time(time.Now().Add(time.Hour)), 196 | }, 197 | }, 198 | { 199 | name: "static assume role with external ID - external ID is used", 200 | authSettings: Settings{ 201 | AuthType: AuthTypeKeys, 202 | AccessKey: "tensile", 203 | SecretKey: "diaphanous", 204 | Region: "eu-north-1", 205 | AssumeRoleARN: "arn:aws:iam::1234567890:role/aws-service-role", 206 | ExternalID: "cows_with_parasols", 207 | }, 208 | assumedCredentials: &ststypes.Credentials{ 209 | AccessKeyId: aws.String("assumed"), 210 | SecretAccessKey: aws.String("role"), 211 | SessionToken: aws.String("session"), 212 | Expiration: aws.Time(time.Now().Add(time.Hour)), 213 | }, 214 | }, 215 | { 216 | name: "static assume role with sts endpoint - endpoint is nil", 217 | authSettings: Settings{ 218 | AuthType: AuthTypeKeys, 219 | AccessKey: "tensile", 220 | SecretKey: "diaphanous", 221 | Region: "us-east-1", 222 | Endpoint: "sts.us-east-1.amazonaws.com", 223 | AssumeRoleARN: "arn:aws:iam::1234567890:role/aws-service-role", 224 | }, 225 | assumedCredentials: &ststypes.Credentials{ 226 | AccessKeyId: aws.String("assumed"), 227 | SecretAccessKey: aws.String("role"), 228 | SessionToken: aws.String("session"), 229 | Expiration: aws.Time(time.Now().Add(time.Hour)), 230 | }, 231 | }, 232 | { 233 | name: "static assume role with failure", 234 | authSettings: Settings{ 235 | AuthType: "keys", 236 | AccessKey: "tensile", 237 | SecretKey: "diaphanous", 238 | Region: "eu-north-1", 239 | AssumeRoleARN: "arn:aws:iam::1234567890:role/aws-service-role", 240 | }, 241 | assumeRoleShouldFail: true, 242 | }, 243 | }.runAll(t) 244 | } 245 | 246 | func TestGetAWSConfig_Default(t *testing.T) { 247 | testSuite{ 248 | { 249 | name: "default reads from environment", 250 | authSettings: Settings{ 251 | AuthType: AuthTypeDefault, 252 | }, 253 | environment: map[string]string{ 254 | "AWS_ACCESS_KEY_ID": "something", 255 | "AWS_SECRET_ACCESS_KEY": "beautiful", 256 | "AWS_REGION": "us-north-1", 257 | }, 258 | }, 259 | { 260 | name: "default reads from credentials file", 261 | authSettings: Settings{ 262 | AuthType: "default", 263 | }, 264 | environment: map[string]string{ 265 | "AWS_SHARED_CREDENTIALS_FILE": testDataPath("credentials"), 266 | }, 267 | }, 268 | }.runAll(t) 269 | } 270 | 271 | func TestGetAWSConfig_Shared(t *testing.T) { 272 | testSuite{ 273 | { 274 | name: "shared reads from specified file", 275 | authSettings: Settings{ 276 | AuthType: AuthTypeSharedCreds, 277 | CredentialsPath: testDataPath("shared_credentials"), 278 | CredentialsProfile: "shared_profile", 279 | }, 280 | }, 281 | { 282 | name: "grafana assume role uses the shared mechanism", 283 | authSettings: Settings{ 284 | AuthType: AuthTypeGrafanaAssumeRole, 285 | AssumeRoleARN: "arn:aws:iam::1234567890:role/customer-role", 286 | }, 287 | environment: map[string]string{ 288 | "AWS_SHARED_CREDENTIALS_FILE": testDataPath("assume_role_credentials"), 289 | }, 290 | assumedCredentials: &ststypes.Credentials{ 291 | AccessKeyId: aws.String("horses"), 292 | SecretAccessKey: aws.String("unicorns"), 293 | SessionToken: aws.String("riding"), 294 | Expiration: aws.Time(time.Now().Add(time.Hour)), 295 | }, 296 | }, 297 | }.runAll(t) 298 | } 299 | 300 | func TestGetAWSConfig_UnknownOrMissing(t *testing.T) { 301 | testSuite{ 302 | { 303 | name: "unknown auth type fails", 304 | authSettings: Settings{ 305 | AuthType: AuthTypeUnknown, 306 | }, 307 | shouldError: true, 308 | }, 309 | { 310 | name: "random auth type fails", 311 | authSettings: Settings{ 312 | AuthType: "rainbows", 313 | }, 314 | shouldError: true, 315 | }, 316 | { 317 | name: "missing auth type fails back to legacy default (and does not fail)", 318 | authSettings: Settings{}, 319 | environment: map[string]string{ 320 | "AWS_SHARED_CREDENTIALS_FILE": testDataPath("credentials"), 321 | }, 322 | shouldError: false, 323 | }, 324 | }.runAll(t) 325 | } 326 | 327 | func TestGetAWSConfig_EC2IAMRole(t *testing.T) { 328 | // TODO 329 | t.Skip() 330 | } 331 | -------------------------------------------------------------------------------- /pkg/awsauth/authtype.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import "github.com/grafana/grafana-aws-sdk/pkg/awsds" 4 | 5 | // AuthType enumerates the kinds of authentication that are supported 6 | type AuthType string 7 | 8 | var ( 9 | AuthTypeDefault AuthType = "default" 10 | AuthTypeSharedCreds AuthType = "credentials" 11 | AuthTypeKeys AuthType = "keys" 12 | AuthTypeEC2IAMRole AuthType = "ec2_iam_role" 13 | AuthTypeGrafanaAssumeRole AuthType = "grafana_assume_role" 14 | AuthTypeUnknown AuthType = "unknown" 15 | AuthTypeMissing AuthType = "" 16 | ) 17 | 18 | func fromLegacy(at awsds.AuthType) AuthType { 19 | switch at { 20 | case awsds.AuthTypeDefault: 21 | return AuthTypeDefault 22 | case awsds.AuthTypeSharedCreds: 23 | return AuthTypeSharedCreds 24 | case awsds.AuthTypeKeys: 25 | return AuthTypeKeys 26 | case awsds.AuthTypeEC2IAMRole: 27 | return AuthTypeEC2IAMRole 28 | case awsds.AuthTypeGrafanaAssumeRole: 29 | return AuthTypeGrafanaAssumeRole 30 | default: 31 | return AuthTypeUnknown 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/awsauth/settings.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "hash/fnv" 7 | "net/http" 8 | "os" 9 | "runtime" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/aws/aws-sdk-go-v2/aws/middleware" 14 | 15 | "github.com/aws/aws-sdk-go-v2/aws" 16 | "github.com/aws/aws-sdk-go-v2/config" 17 | "github.com/aws/aws-sdk-go-v2/credentials/stscreds" 18 | smithymiddleware "github.com/aws/smithy-go/middleware" 19 | 20 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 21 | "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" 22 | "github.com/grafana/grafana-plugin-sdk-go/build" 23 | ) 24 | 25 | const ( 26 | // awsTempCredsAccessKey and awsTempCredsSecretKey are the files containing the 27 | awsTempCredsAccessKey = "/tmp/aws.credentials/access-key-id" 28 | awsTempCredsSecretKey = "/tmp/aws.credentials/secret-access-key" 29 | ) 30 | 31 | // Settings carries configuration for authenticating with AWS 32 | type Settings struct { 33 | AuthType AuthType 34 | // deprecated: use AuthType instead 35 | LegacyAuthType awsds.AuthType 36 | AccessKey string 37 | SecretKey string 38 | Region string 39 | CredentialsPath string 40 | CredentialsProfile string 41 | AssumeRoleARN string 42 | Endpoint string 43 | ExternalID string 44 | UserAgent string 45 | SessionToken string 46 | HTTPClient *http.Client 47 | ProxyOptions *proxy.Options 48 | } 49 | 50 | // Hash returns a value suitable for caching the config associated with these settings 51 | func (s Settings) Hash() uint64 { 52 | h := fnv.New64() 53 | // In theory all of these except for region will be moot, because if any of them 54 | // change the datasource instance will be recycled. However, to ensure no leakage 55 | // of credentials between instances, we check everything except proxy options. 56 | // If those change the datasource will definitely not be reused. 57 | _, _ = h.Write([]byte(s.GetAuthType())) 58 | _, _ = h.Write([]byte(s.AccessKey)) 59 | _, _ = h.Write([]byte(s.SecretKey)) 60 | _, _ = h.Write([]byte(s.Region)) 61 | _, _ = h.Write([]byte(s.CredentialsPath)) 62 | _, _ = h.Write([]byte(s.CredentialsProfile)) 63 | _, _ = h.Write([]byte(s.AssumeRoleARN)) 64 | _, _ = h.Write([]byte(s.Endpoint)) 65 | _, _ = h.Write([]byte(s.ExternalID)) 66 | return h.Sum64() 67 | } 68 | 69 | func (s Settings) GetAuthType() AuthType { 70 | if s.AuthType != AuthTypeMissing { 71 | return s.AuthType 72 | } 73 | return fromLegacy(s.LegacyAuthType) 74 | } 75 | 76 | func (s Settings) BaseOptions() []LoadOptionsFunc { 77 | return []LoadOptionsFunc{s.WithRegion(), s.WithEndpoint(), s.WithHTTPClient(), s.WithUserAgent()} 78 | } 79 | 80 | func (s Settings) WithRegion() LoadOptionsFunc { 81 | return func(opts *config.LoadOptions) error { 82 | if s.Region != "" && s.Region != "default" { 83 | opts.Region = s.Region 84 | } 85 | return nil 86 | } 87 | } 88 | 89 | func (s Settings) WithEndpoint() LoadOptionsFunc { 90 | useFips := false 91 | if strings.Contains(s.Endpoint, "-fips.") || strings.Contains(s.Region, "us-gov") { 92 | // TODO: add fips support as an toggle option 93 | s.Endpoint = "" 94 | useFips = true 95 | } 96 | return func(options *config.LoadOptions) error { 97 | if s.Endpoint != "" && s.Endpoint != "default" { 98 | options.BaseEndpoint = s.Endpoint 99 | } 100 | if useFips { 101 | options.UseFIPSEndpoint = aws.FIPSEndpointStateEnabled 102 | } 103 | return nil 104 | } 105 | } 106 | 107 | func (s Settings) WithStaticCredentials(client AWSAPIClient) LoadOptionsFunc { 108 | return func(opts *config.LoadOptions) error { 109 | opts.Credentials = client.NewStaticCredentialsProvider(s.AccessKey, s.SecretKey, s.SessionToken) 110 | return nil 111 | } 112 | } 113 | 114 | // WithSharedCredentials returns a LoadOptionsFunc to initialize config from a credentials file 115 | func (s Settings) WithSharedCredentials() LoadOptionsFunc { 116 | return func(options *config.LoadOptions) error { 117 | options.SharedConfigProfile = s.CredentialsProfile 118 | if s.CredentialsPath != "" { 119 | options.SharedCredentialsFiles = []string{s.CredentialsPath} 120 | } 121 | return nil 122 | } 123 | } 124 | 125 | // WithGrafanaAssumeRole returns a LoadOptionsFunc to initialize config for Grafana Assume Role 126 | func (s Settings) WithGrafanaAssumeRole(ctx context.Context, client AWSAPIClient) LoadOptionsFunc { 127 | accessKey, keyErr := os.ReadFile(awsTempCredsAccessKey) 128 | secretKey, secretErr := os.ReadFile(awsTempCredsSecretKey) 129 | if keyErr == nil && secretErr == nil { 130 | return func(opts *config.LoadOptions) error { 131 | opts.Credentials = client.NewStaticCredentialsProvider(string(accessKey), string(secretKey), "") 132 | return nil 133 | } 134 | } 135 | 136 | // if we don't find the files assume it's running single tenant and use the credentials file 137 | return func(options *config.LoadOptions) error { 138 | options.SharedConfigProfile = awsds.ProfileName 139 | if s.CredentialsPath != "" { 140 | options.SharedCredentialsFiles = []string{s.CredentialsPath} 141 | } 142 | return nil 143 | } 144 | } 145 | 146 | func (s Settings) WithAssumeRole(cfg aws.Config, client AWSAPIClient) LoadOptionsFunc { 147 | stsClient := client.NewSTSClientFromConfig(cfg) 148 | provider := client.NewAssumeRoleProvider(stsClient, s.AssumeRoleARN, func(options *stscreds.AssumeRoleOptions) { 149 | if s.ExternalID != "" { 150 | options.ExternalID = aws.String(s.ExternalID) 151 | } 152 | }) 153 | cache := client.NewCredentialsCache(provider) 154 | return func(options *config.LoadOptions) error { 155 | options.Credentials = cache 156 | if isStsEndpoint(cfg.BaseEndpoint) { 157 | options.BaseEndpoint = "" 158 | } 159 | return nil 160 | } 161 | } 162 | 163 | func (s Settings) WithEC2RoleCredentials(client AWSAPIClient) LoadOptionsFunc { 164 | return func(options *config.LoadOptions) error { 165 | options.Credentials = client.NewEC2RoleCreds() 166 | return nil 167 | } 168 | } 169 | 170 | func (s Settings) WithHTTPClient() LoadOptionsFunc { 171 | return func(options *config.LoadOptions) error { 172 | if s.HTTPClient != nil { 173 | options.HTTPClient = s.HTTPClient 174 | } 175 | if options.HTTPClient == nil { 176 | options.HTTPClient = http.DefaultClient 177 | } 178 | if s.ProxyOptions != nil { 179 | if client, ok := options.HTTPClient.(*http.Client); ok { 180 | if client.Transport == nil { 181 | client.Transport = http.DefaultTransport.(*http.Transport).Clone() 182 | } 183 | if transport, ok := client.Transport.(*http.Transport); ok { 184 | err := proxy.New(s.ProxyOptions).ConfigureSecureSocksHTTPProxy(transport) 185 | if err != nil { 186 | return fmt.Errorf("error configuring Secure Socks proxy for Transport: %w", err) 187 | } 188 | } else { 189 | return fmt.Errorf("cfg.HTTPClient.Transport is not *http.Transport") 190 | } 191 | } else { 192 | return fmt.Errorf("cfg.HTTPClient is not *http.Client") 193 | } 194 | } 195 | return nil 196 | } 197 | } 198 | 199 | // WithUserAgent adds info to the UserAgent header of API requests. 200 | // Adapted from grafana-aws-sdk/pkg/awsds/utils.go 201 | func (s Settings) WithUserAgent() LoadOptionsFunc { 202 | buildInfo, err := build.GetBuildInfo() 203 | version := buildInfo.Version 204 | if err != nil { 205 | version = "dev" 206 | } 207 | grafanaVersion := os.Getenv("GF_VERSION") 208 | if grafanaVersion == "" { 209 | grafanaVersion = "?" 210 | } 211 | _, amgEnv := os.LookupEnv("AMAZON_MANAGED_GRAFANA") 212 | 213 | return func(options *config.LoadOptions) error { 214 | apiOpts := []func(*smithymiddleware.Stack) error{ 215 | middleware.AddUserAgentKeyValue(aws.SDKName, aws.SDKVersion), 216 | middleware.AddUserAgentKey(fmt.Sprintf("(%s; %s;)", runtime.Version(), runtime.GOOS)), 217 | } 218 | if s.UserAgent != "" { 219 | apiOpts = append(apiOpts, middleware.AddUserAgentKeyValue(s.UserAgent, version)) 220 | } 221 | apiOpts = append(apiOpts, 222 | middleware.AddUserAgentKeyValue("Grafana", grafanaVersion), 223 | middleware.AddUserAgentKeyValue("AMG", strconv.FormatBool(amgEnv)), 224 | ) 225 | options.APIOptions = append(options.APIOptions, apiOpts...) 226 | return nil 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /pkg/awsauth/test_utils.go: -------------------------------------------------------------------------------- 1 | package awsauth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/credentials" 9 | "github.com/aws/aws-sdk-go-v2/credentials/stscreds" 10 | "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" 11 | "github.com/aws/aws-sdk-go-v2/service/sts" 12 | ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" 13 | "github.com/stretchr/testify/mock" 14 | ) 15 | 16 | // mockAWSAPIClient is used for internal testing. Most of the aws-sdk-go machinery is used, 17 | // but anything that reaches out to AWS is faked or disabled. 18 | type mockAWSAPIClient struct { 19 | assumeRoleClient *mockAssumeRoleAPIClient 20 | } 21 | 22 | func (m *mockAWSAPIClient) LoadDefaultConfig(ctx context.Context, options ...LoadOptionsFunc) (aws.Config, error) { 23 | opts := []LoadOptionsFunc{func(opts *config.LoadOptions) error { 24 | // Disable using EC2 instance metadata in config loading 25 | opts.EC2IMDSClientEnableState = imds.ClientDisabled 26 | // Disable endpoint discovery to avoid API calls out from tests 27 | opts.EnableEndpointDiscovery = aws.EndpointDiscoveryDisabled 28 | return nil 29 | }} 30 | opts = append(opts, options...) 31 | return config.LoadDefaultConfig(ctx, opts...) 32 | } 33 | 34 | func (m *mockAWSAPIClient) NewStaticCredentialsProvider(key, secret, session string) aws.CredentialsProvider { 35 | return credentials.NewStaticCredentialsProvider(key, secret, session) 36 | } 37 | 38 | func (m *mockAWSAPIClient) NewSTSClientFromConfig(cfg aws.Config) stscreds.AssumeRoleAPIClient { 39 | m.assumeRoleClient.stsConfig = cfg 40 | return m.assumeRoleClient 41 | } 42 | 43 | func (m *mockAWSAPIClient) NewAssumeRoleProvider(client stscreds.AssumeRoleAPIClient, arn string, opts ...func(*stscreds.AssumeRoleOptions)) aws.CredentialsProvider { 44 | return stscreds.NewAssumeRoleProvider(client, arn, opts...) 45 | } 46 | 47 | func (m *mockAWSAPIClient) NewCredentialsCache(provider aws.CredentialsProvider, optFns ...func(options *aws.CredentialsCacheOptions)) aws.CredentialsProvider { 48 | return aws.NewCredentialsCache(provider, optFns...) 49 | } 50 | 51 | func (m *mockAWSAPIClient) NewEC2RoleCreds() aws.CredentialsProvider { 52 | // TODO 53 | panic("not implemented") 54 | } 55 | 56 | type mockAssumeRoleAPIClient struct { 57 | mock.Mock 58 | stsConfig aws.Config 59 | calledExternalId string 60 | } 61 | 62 | func (m *mockAssumeRoleAPIClient) AssumeRole(_ context.Context, params *sts.AssumeRoleInput, _ ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { 63 | args := m.Called() 64 | if params.ExternalId != nil { 65 | m.calledExternalId = *params.ExternalId 66 | } 67 | if args.Bool(0) { // shouldError 68 | return &sts.AssumeRoleOutput{}, fmt.Errorf("assume role failed") 69 | } 70 | return &sts.AssumeRoleOutput{ 71 | AssumedRoleUser: &ststypes.AssumedRoleUser{ 72 | Arn: params.RoleArn, 73 | AssumedRoleId: aws.String("auto-generated-id"), 74 | }, 75 | Credentials: args.Get(1).(*ststypes.Credentials), 76 | }, nil 77 | } 78 | 79 | // NewFakeConfigProvider returns a basic mock satisfying AWSConfigProvider. 80 | // If shouldFail is true, the GetConfig method will fail. Otherwise it will 81 | // return a basic config with static credentials 82 | func NewFakeConfigProvider(shouldFail bool) ConfigProvider { 83 | return fakeConfigProvider{shouldFail} 84 | } 85 | 86 | type fakeConfigProvider struct { 87 | shouldFail bool 88 | } 89 | 90 | var staticCredentials = aws.Credentials{ 91 | AccessKeyID: "hello", 92 | SecretAccessKey: "world", 93 | SessionToken: "(no)", 94 | CanExpire: false, 95 | } 96 | 97 | func (f fakeConfigProvider) GetConfig(_ context.Context, _ Settings) (aws.Config, error) { 98 | if f.shouldFail { 99 | return aws.Config{}, fmt.Errorf("LoadDefaultConfig failed") 100 | } 101 | return aws.Config{Credentials: credentials.StaticCredentialsProvider{Value: staticCredentials}}, nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/awsauth/testdata/assume_role_credentials: -------------------------------------------------------------------------------- 1 | [assume_role_credentials] 2 | aws_access_key_id=ADIFFERENTONENICE 3 | aws_secret_access_key=merrilywerollalong 4 | -------------------------------------------------------------------------------- /pkg/awsauth/testdata/credentials: -------------------------------------------------------------------------------- 1 | [default] 2 | aws_access_key_id=AREGULAROLDKEY 3 | aws_secret_access_key=askmenosecrets 4 | -------------------------------------------------------------------------------- /pkg/awsauth/testdata/shared_credentials: -------------------------------------------------------------------------------- 1 | [shared_profile] 2 | aws_access_key_id=AFAKEONEYESGOOD 3 | aws_secret_access_key=zippitydoodah 4 | -------------------------------------------------------------------------------- /pkg/awsds/asyncDatasource.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "sync" 10 | 11 | "github.com/grafana/grafana-plugin-sdk-go/backend" 12 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 13 | "github.com/grafana/grafana-plugin-sdk-go/data" 14 | "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" 15 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 16 | "github.com/grafana/sqlds/v4" 17 | ) 18 | 19 | const defaultKeySuffix = "default" 20 | const fromAlertHeader = "FromAlert" 21 | const fromExpressionHeader = "http_X-Grafana-From-Expr" 22 | 23 | func defaultKey(datasourceUID string) string { 24 | return fmt.Sprintf("%s-%s", datasourceUID, defaultKeySuffix) 25 | } 26 | 27 | func keyWithConnectionArgs(datasourceUID string, connArgs json.RawMessage) string { 28 | return fmt.Sprintf("%s-%s", datasourceUID, string(connArgs)) 29 | } 30 | 31 | type dbConnection struct { 32 | db AsyncDB 33 | settings backend.DataSourceInstanceSettings 34 | } 35 | 36 | type AsyncAWSDatasource struct { 37 | *sqlds.SQLDatasource 38 | 39 | dbConnections sync.Map 40 | driver AsyncDriver 41 | sqldsQueryDataHandler backend.QueryDataHandlerFunc 42 | } 43 | 44 | func (ds *AsyncAWSDatasource) getDBConnection(key string) (dbConnection, bool) { 45 | conn, ok := ds.dbConnections.Load(key) 46 | if !ok { 47 | return dbConnection{}, false 48 | } 49 | return conn.(dbConnection), true 50 | } 51 | 52 | func (ds *AsyncAWSDatasource) storeDBConnection(key string, dbConn dbConnection) { 53 | ds.dbConnections.Store(key, dbConn) 54 | } 55 | 56 | func getDatasourceUID(settings backend.DataSourceInstanceSettings) string { 57 | datasourceUID := settings.UID 58 | // Grafana < 8.0 won't include the UID yet 59 | if datasourceUID == "" { 60 | datasourceUID = fmt.Sprintf("%d", settings.ID) 61 | } 62 | return datasourceUID 63 | } 64 | 65 | func NewAsyncAWSDatasource(driver AsyncDriver) *AsyncAWSDatasource { 66 | sqlDs := sqlds.NewDatasource(driver) 67 | return &AsyncAWSDatasource{ 68 | SQLDatasource: sqlDs, 69 | driver: driver, 70 | sqldsQueryDataHandler: sqlDs.QueryData, 71 | } 72 | } 73 | 74 | // isAsyncFlow checks the feature flag in query to see if it is async 75 | func isAsyncFlow(query backend.DataQuery) bool { 76 | q, _ := GetQuery(query) 77 | return q.Meta.QueryFlow == "async" 78 | } 79 | 80 | func (ds *AsyncAWSDatasource) NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 81 | db, err := ds.driver.GetAsyncDB(ctx, settings, nil) 82 | if err != nil { 83 | return nil, err 84 | } 85 | key := defaultKey(getDatasourceUID(settings)) 86 | ds.storeDBConnection(key, dbConnection{db, settings}) 87 | 88 | // initialize the wrapped ds.SQLDatasource 89 | _, err = ds.SQLDatasource.NewDatasource(ctx, settings) 90 | return ds, err 91 | } 92 | 93 | func (ds *AsyncAWSDatasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { 94 | syncExectionEnabled := false 95 | for _, query := range req.Queries { 96 | if !isAsyncFlow(query) { 97 | syncExectionEnabled = true 98 | break 99 | } 100 | } 101 | 102 | _, isFromAlert := req.Headers[fromAlertHeader] 103 | _, isFromExpression := req.Headers[fromExpressionHeader] 104 | if syncExectionEnabled || isFromAlert || isFromExpression { 105 | return ds.sqldsQueryDataHandler.QueryData(ctx, req) 106 | } 107 | 108 | // async flow 109 | var ( 110 | response = sqlds.NewResponse(backend.NewQueryDataResponse()) 111 | wg = sync.WaitGroup{} 112 | ) 113 | 114 | // Execute each query and store the results by query RefID 115 | for _, q := range req.Queries { 116 | wg.Add(1) 117 | go func(query backend.DataQuery) { 118 | var frames data.Frames 119 | var err error 120 | frames, err = ds.handleAsyncQuery(ctx, query, req.PluginContext.DataSourceInstanceSettings.UID) 121 | if err != nil { 122 | errorResponse := errorsource.Response(err) 123 | var qeError *QueryExecutionError 124 | // checking if we know the cause of downstream error 125 | if errors.As(err, &qeError) { 126 | errorResponse.Status = backend.StatusInternal 127 | switch qeError.Cause { 128 | // make sure error.status matches the downstream cause, if provided 129 | case QueryFailedInternal: 130 | errorResponse.Status = backend.StatusInternal 131 | case QueryFailedUser: 132 | errorResponse.Status = backend.StatusBadRequest 133 | } 134 | } 135 | response.Set(query.RefID, errorResponse) 136 | } else { 137 | response.Set(query.RefID, backend.DataResponse{Frames: frames}) 138 | } 139 | 140 | wg.Done() 141 | }(q) 142 | } 143 | 144 | wg.Wait() 145 | return response.Response(), nil 146 | } 147 | 148 | func (ds *AsyncAWSDatasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { 149 | datasourceUID := req.PluginContext.DataSourceInstanceSettings.UID 150 | key := defaultKey(datasourceUID) 151 | dbConn, ok := ds.getDBConnection(key) 152 | if !ok { 153 | return &backend.CheckHealthResult{ 154 | Status: backend.HealthStatusError, 155 | Message: "No database connection found for datasource uid: " + datasourceUID, 156 | }, nil 157 | } 158 | err := dbConn.db.Ping(ctx) 159 | if err != nil { 160 | return &backend.CheckHealthResult{ 161 | Status: backend.HealthStatusError, 162 | Message: err.Error(), 163 | }, nil 164 | } 165 | return &backend.CheckHealthResult{ 166 | Status: backend.HealthStatusOk, 167 | Message: "Data source is working", 168 | }, nil 169 | } 170 | 171 | func (ds *AsyncAWSDatasource) getAsyncDBFromQuery(ctx context.Context, q *AsyncQuery, datasourceUID string) (AsyncDB, error) { 172 | if !ds.EnableMultipleConnections && len(q.ConnectionArgs) > 0 { 173 | return nil, sqlds.ErrorMissingMultipleConnectionsConfig 174 | } 175 | // The database connection may vary depending on query arguments 176 | // The raw arguments are used as key to store the db connection in memory so they can be reused 177 | key := defaultKey(datasourceUID) 178 | dbConn, ok := ds.getDBConnection(key) 179 | if !ok { 180 | return nil, sqlds.ErrorMissingDBConnection 181 | } 182 | if !ds.EnableMultipleConnections || len(q.ConnectionArgs) == 0 { 183 | return dbConn.db, nil 184 | } 185 | 186 | key = keyWithConnectionArgs(datasourceUID, q.ConnectionArgs) 187 | if cachedConn, ok := ds.getDBConnection(key); ok { 188 | return cachedConn.db, nil 189 | } 190 | 191 | var err error 192 | db, err := ds.driver.GetAsyncDB(ctx, dbConn.settings, q.ConnectionArgs) 193 | if err != nil { 194 | return nil, err 195 | } 196 | // Assign this connection in the cache 197 | dbConn = dbConnection{db, dbConn.settings} 198 | ds.storeDBConnection(key, dbConn) 199 | 200 | return dbConn.db, nil 201 | } 202 | 203 | type queryMeta struct { 204 | QueryID string `json:"queryID"` 205 | Status string `json:"status"` 206 | } 207 | 208 | // handleQuery will call query, and attempt to reconnect if the query failed 209 | func (ds *AsyncAWSDatasource) handleAsyncQuery(ctx context.Context, req backend.DataQuery, datasourceUID string) (data.Frames, error) { 210 | // Convert the backend.DataQuery into a Query object 211 | q, err := GetQuery(req) 212 | if err != nil { 213 | return getErrorFrameFromQuery(q), err 214 | } 215 | 216 | // Apply supported macros to the query 217 | q.RawSQL, err = sqlutil.Interpolate(&q.Query, ds.driver.Macros()) 218 | if err != nil { 219 | return getErrorFrameFromQuery(q), fmt.Errorf("%s: %w", "Could not apply macros", err) 220 | } 221 | 222 | // Apply the default FillMode, overwritting it if the query specifies it 223 | driverSettings := ds.SQLDatasource.DriverSettings() 224 | fillMode := driverSettings.FillMode 225 | if q.FillMissing != nil { 226 | fillMode = q.FillMissing 227 | } 228 | 229 | asyncDB, err := ds.getAsyncDBFromQuery(ctx, q, datasourceUID) 230 | if err != nil { 231 | return getErrorFrameFromQuery(q), err 232 | } 233 | 234 | if q.QueryID == "" { 235 | queryID, err := startQuery(ctx, asyncDB, q) 236 | if err != nil { 237 | return getErrorFrameFromQuery(q), err 238 | } 239 | return data.Frames{ 240 | {Meta: &data.FrameMeta{ 241 | ExecutedQueryString: q.RawSQL, 242 | Custom: queryMeta{QueryID: queryID, Status: "started"}}, 243 | }, 244 | }, nil 245 | } 246 | 247 | status, err := queryStatus(ctx, asyncDB, q) 248 | if err != nil { 249 | return getErrorFrameFromQuery(q), err 250 | } 251 | customMeta := queryMeta{QueryID: q.QueryID, Status: status.String()} 252 | if status != QueryFinished { 253 | return data.Frames{ 254 | {Meta: &data.FrameMeta{ 255 | ExecutedQueryString: q.RawSQL, 256 | Custom: customMeta}, 257 | }, 258 | }, nil 259 | } 260 | 261 | dbConn, _ := ds.getDBConnection(defaultKey(datasourceUID)) 262 | db, err := ds.GetDBFromQuery(ctx, &q.Query) 263 | if err != nil { 264 | return getErrorFrameFromQuery(q), err 265 | } 266 | res, err := queryAsync(ctx, db, dbConn.settings, ds.driver.Converters(), fillMode, q, ds.GetRowLimit()) 267 | if err == nil || errors.Is(err, sqlds.ErrorNoResults) { 268 | if len(res) == 0 { 269 | res = append(res, &data.Frame{}) 270 | } 271 | res[0].Meta.Custom = customMeta 272 | return res, nil 273 | } 274 | 275 | return getErrorFrameFromQuery(q), err 276 | } 277 | 278 | func queryAsync(ctx context.Context, conn *sql.DB, settings backend.DataSourceInstanceSettings, converters []sqlutil.Converter, fillMode *data.FillMissing, q *AsyncQuery, rowLimit int64) (data.Frames, error) { 279 | query := sqlds.NewQuery(conn, settings, converters, fillMode, rowLimit) 280 | return query.Run(ctx, &q.Query, sql.NamedArg{Name: "queryID", Value: q.QueryID}) 281 | } 282 | -------------------------------------------------------------------------------- /pkg/awsds/asyncDatasource_test.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "encoding/json" 7 | "fmt" 8 | "sync" 9 | "testing" 10 | 11 | "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" 12 | 13 | "github.com/grafana/grafana-plugin-sdk-go/backend" 14 | "github.com/grafana/sqlds/v4" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/mock" 17 | ) 18 | 19 | type fakeAsyncDB struct{} 20 | 21 | func (fakeAsyncDB) Begin() (driver.Tx, error) { return nil, nil } 22 | func (fakeAsyncDB) Prepare(query string) (driver.Stmt, error) { return nil, nil } 23 | func (fakeAsyncDB) Close() error { return nil } 24 | func (fakeAsyncDB) Ping(ctx context.Context) error { return nil } 25 | func (fakeAsyncDB) CancelQuery(ctx context.Context, queryID string) error { return nil } 26 | func (fakeAsyncDB) GetRows(ctx context.Context, queryID string) (driver.Rows, error) { return nil, nil } 27 | 28 | func (fakeAsyncDB) GetQueryID(ctx context.Context, query string, args ...interface{}) (bool, string, error) { 29 | return false, "", nil 30 | } 31 | 32 | func (fakeAsyncDB) QueryStatus(ctx context.Context, queryID string) (QueryStatus, error) { 33 | return QueryUnknown, nil 34 | } 35 | 36 | func (fakeAsyncDB) StartQuery(ctx context.Context, query string, args ...interface{}) (string, error) { 37 | return "", nil 38 | } 39 | 40 | type fakeDriver struct { 41 | openDBfn func() (AsyncDB, error) 42 | AsyncDriver 43 | } 44 | 45 | func (d fakeDriver) GetAsyncDB(context.Context, backend.DataSourceInstanceSettings, json.RawMessage) (db AsyncDB, err error) { 46 | return d.openDBfn() 47 | } 48 | 49 | func Test_getDBConnectionFromQuery(t *testing.T) { 50 | db := &fakeAsyncDB{} 51 | db2 := &fakeAsyncDB{} 52 | db3 := &fakeAsyncDB{} 53 | d := &fakeDriver{openDBfn: func() (AsyncDB, error) { return db3, nil }} 54 | tests := []struct { 55 | desc string 56 | dsUID string 57 | args string 58 | existingDB AsyncDB 59 | expectedKey string 60 | expectedDB AsyncDB 61 | }{ 62 | { 63 | desc: "it should return the default db with no args", 64 | dsUID: "uid1", 65 | args: "", 66 | expectedKey: "uid1-default", 67 | expectedDB: db, 68 | }, 69 | { 70 | desc: "it should return the cached connection for the given args", 71 | dsUID: "uid1", 72 | args: "foo", 73 | expectedKey: "uid1-foo", 74 | existingDB: db2, 75 | expectedDB: db2, 76 | }, 77 | { 78 | desc: "it should create a new connection with the given args", 79 | dsUID: "uid1", 80 | args: "foo", 81 | expectedKey: "uid1-foo", 82 | expectedDB: db3, 83 | }, 84 | } 85 | for _, tt := range tests { 86 | t.Run(tt.desc, func(t *testing.T) { 87 | ds := &AsyncAWSDatasource{driver: d, SQLDatasource: &sqlds.SQLDatasource{EnableMultipleConnections: true}} 88 | settings := backend.DataSourceInstanceSettings{UID: tt.dsUID} 89 | key := defaultKey(tt.dsUID) 90 | // Add the mandatory default db 91 | ds.storeDBConnection(key, dbConnection{db, settings}) 92 | if tt.args != "" { 93 | key = keyWithConnectionArgs(tt.dsUID, []byte(tt.args)) 94 | } 95 | if tt.existingDB != nil { 96 | ds.storeDBConnection(key, dbConnection{tt.existingDB, settings}) 97 | } 98 | 99 | dbConn, err := ds.getAsyncDBFromQuery(context.Background(), &AsyncQuery{Query: sqlutil.Query{ConnectionArgs: json.RawMessage(tt.args)}}, tt.dsUID) 100 | if err != nil { 101 | t.Fatalf("unexpected error %v", err) 102 | } 103 | if key != tt.expectedKey { 104 | t.Fatalf("unexpected cache key %s", key) 105 | } 106 | if dbConn != tt.expectedDB { 107 | t.Fatalf("unexpected result %v", dbConn) 108 | } 109 | }) 110 | } 111 | } 112 | 113 | func Test_Async_QueryData_uses_synchronous_flow_when_header_has_alert_and_expression(t *testing.T) { 114 | tests := []struct { 115 | desc string 116 | headers map[string]string 117 | }{ 118 | { 119 | "alert header", 120 | map[string]string{fromAlertHeader: "some value"}, 121 | }, 122 | { 123 | "expression Header", 124 | map[string]string{fromExpressionHeader: "some value"}, 125 | }, 126 | } 127 | for _, tt := range tests { 128 | t.Run(tt.desc, func(t *testing.T) { 129 | syncCalled := false 130 | mockQueryData := func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { 131 | syncCalled = true 132 | return nil, nil 133 | } 134 | ds := &AsyncAWSDatasource{sqldsQueryDataHandler: mockQueryData} 135 | 136 | _, err := ds.QueryData(context.Background(), &backend.QueryDataRequest{Headers: tt.headers}) 137 | assert.NoError(t, err) 138 | assert.True(t, syncCalled) 139 | }) 140 | } 141 | } 142 | 143 | type MockDB struct { 144 | mock.Mock 145 | } 146 | 147 | func (m *MockDB) Ping(context context.Context) error { 148 | args := m.Called(context) 149 | return args.Error(0) 150 | } 151 | 152 | func (m *MockDB) Begin() (driver.Tx, error) { 153 | args := m.Called() 154 | return args.Get(0).(driver.Tx), args.Error(1) 155 | } 156 | 157 | func (m *MockDB) CancelQuery(ctx context.Context, queryID string) error { 158 | args := m.Called(ctx, queryID) 159 | return args.Error(0) 160 | } 161 | func (m *MockDB) Close() error { 162 | args := m.Called() 163 | return args.Error(0) 164 | } 165 | func (m *MockDB) GetQueryID(ctx context.Context, query string, args ...interface{}) (bool, string, error) { 166 | arg := m.Called(ctx, query, args) 167 | return arg.Bool(0), arg.String(1), arg.Error(2) 168 | } 169 | func (m *MockDB) GetRows(ctx context.Context, queryID string) (driver.Rows, error) { 170 | args := m.Called(ctx, queryID) 171 | return args.Get(0).(driver.Rows), args.Error(1) 172 | } 173 | func (m *MockDB) Prepare(query string) (driver.Stmt, error) { 174 | args := m.Called(query) 175 | return args.Get(0).(driver.Stmt), args.Error(1) 176 | } 177 | func (m *MockDB) QueryStatus(ctx context.Context, queryID string) (QueryStatus, error) { 178 | args := m.Called(ctx, queryID) 179 | return args.Get(0).(QueryStatus), args.Error(1) 180 | } 181 | func (m *MockDB) StartQuery(ctx context.Context, query string, args ...interface{}) (string, error) { 182 | arg := m.Called(ctx, query, args) 183 | return arg.String(0), arg.Error(1) 184 | } 185 | 186 | func Test_AsyncDatasource_CheckHealth(t *testing.T) { 187 | tests := []struct { 188 | desc string 189 | mockPingResponse error 190 | expected *backend.CheckHealthResult 191 | }{ 192 | { 193 | desc: "it returns an error when ping fails", 194 | mockPingResponse: fmt.Errorf("your auth wasn't right"), 195 | expected: &backend.CheckHealthResult{ 196 | Status: backend.HealthStatusError, 197 | Message: "your auth wasn't right", 198 | }, 199 | }, 200 | { 201 | desc: "it returns an ok when the query succeeds", 202 | mockPingResponse: nil, 203 | expected: &backend.CheckHealthResult{ 204 | Status: backend.HealthStatusOk, 205 | Message: "Data source is working", 206 | }, 207 | }, 208 | } 209 | for _, tt := range tests { 210 | t.Run(tt.desc, func(t *testing.T) { 211 | db := new(MockDB) 212 | db.On("Ping", context.Background()).Return(tt.mockPingResponse) 213 | dbC := dbConnection{ 214 | db, 215 | backend.DataSourceInstanceSettings{UID: "uid1"}, 216 | } 217 | ds := &AsyncAWSDatasource{dbConnections: sync.Map{}} 218 | ds.dbConnections.Store(defaultKey("uid1"), dbC) 219 | 220 | result, err := ds.CheckHealth(context.Background(), &backend.CheckHealthRequest{ 221 | PluginContext: backend.PluginContext{ 222 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{UID: "uid1"}, 223 | }, 224 | }) 225 | assert.NoError(t, err) 226 | assert.Equal(t, tt.expected, result) 227 | }) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /pkg/awsds/authSettings.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" 12 | "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" 13 | ) 14 | 15 | const ( 16 | // AllowedAuthProvidersEnvVarKeyName is the string literal for the aws allowed auth providers environment variable key name 17 | AllowedAuthProvidersEnvVarKeyName = "AWS_AUTH_AllowedAuthProviders" 18 | 19 | // AssumeRoleEnabledEnvVarKeyName is the string literal for the aws assume role enabled environment variable key name 20 | AssumeRoleEnabledEnvVarKeyName = "AWS_AUTH_AssumeRoleEnabled" 21 | 22 | // SessionDurationEnvVarKeyName is the string literal for the session duration variable key name 23 | SessionDurationEnvVarKeyName = "AWS_AUTH_SESSION_DURATION" 24 | 25 | // GrafanaAssumeRoleExternalIdKeyName is the string literal for the grafana assume role external id environment variable key name 26 | GrafanaAssumeRoleExternalIdKeyName = "AWS_AUTH_EXTERNAL_ID" 27 | 28 | // ListMetricsPageLimitKeyName is the string literal for the cloudwatch list metrics page limit key name 29 | ListMetricsPageLimitKeyName = "AWS_CW_LIST_METRICS_PAGE_LIMIT" 30 | 31 | // SigV4AuthEnabledEnvVarKeyName is the string literal for the sigv4 auth enabled environment variable key name 32 | SigV4AuthEnabledEnvVarKeyName = "AWS_SIGV4_AUTH_ENABLED" 33 | 34 | // SigV4VerboseLoggingEnvVarKeyName is the string literal for the sigv4 verbose logging environment variable key name 35 | SigV4VerboseLoggingEnvVarKeyName = "AWS_SIGV4_VERBOSE_LOGGING" 36 | 37 | defaultAssumeRoleEnabled = true 38 | defaultListMetricsPageLimit = 500 39 | defaultSecureSocksDSProxyEnabled = false 40 | ) 41 | 42 | // ReadAuthSettings gets the Grafana auth settings from the context if its available, the environment variables if not 43 | // Note: This function is mainly for backwards compatibility with older versions of Grafana; generally 44 | // ReadAuthSettingsFromContext should be used instead 45 | func ReadAuthSettings(ctx context.Context) *AuthSettings { 46 | settings, exists := ReadAuthSettingsFromContext(ctx) 47 | if !exists { 48 | settings = ReadAuthSettingsFromEnvironmentVariables() 49 | } 50 | return settings 51 | } 52 | 53 | func defaultAuthSettings() *AuthSettings { 54 | return &AuthSettings{ 55 | AllowedAuthProviders: []string{"default", "keys", "credentials"}, 56 | AssumeRoleEnabled: defaultAssumeRoleEnabled, 57 | SessionDuration: &stscreds.DefaultDuration, 58 | ListMetricsPageLimit: defaultListMetricsPageLimit, 59 | SecureSocksDSProxyEnabled: defaultSecureSocksDSProxyEnabled, 60 | } 61 | } 62 | 63 | // ReadAuthSettingsFromContext tries to get the auth settings from the GrafanaConfig in ctx, and returns true if it finds a config 64 | func ReadAuthSettingsFromContext(ctx context.Context) (*AuthSettings, bool) { 65 | cfg := backend.GrafanaConfigFromContext(ctx) 66 | // initialize settings with the default values set 67 | settings := defaultAuthSettings() 68 | if cfg == nil { 69 | return settings, false 70 | } 71 | hasSettings := false 72 | 73 | if providers := cfg.Get(AllowedAuthProvidersEnvVarKeyName); providers != "" { 74 | allowedAuthProviders := []string{} 75 | for _, authProvider := range strings.Split(providers, ",") { 76 | authProvider = strings.TrimSpace(authProvider) 77 | if authProvider != "" { 78 | allowedAuthProviders = append(allowedAuthProviders, authProvider) 79 | } 80 | } 81 | if len(allowedAuthProviders) != 0 { 82 | settings.AllowedAuthProviders = allowedAuthProviders 83 | } 84 | hasSettings = true 85 | } 86 | 87 | if v := cfg.Get(AssumeRoleEnabledEnvVarKeyName); v != "" { 88 | assumeRoleEnabled, err := strconv.ParseBool(v) 89 | if err == nil { 90 | settings.AssumeRoleEnabled = assumeRoleEnabled 91 | } else { 92 | backend.Logger.Error("could not parse context variable", "var", AllowedAuthProvidersEnvVarKeyName) 93 | } 94 | hasSettings = true 95 | } 96 | 97 | if v := cfg.Get(GrafanaAssumeRoleExternalIdKeyName); v != "" { 98 | settings.ExternalID = v 99 | hasSettings = true 100 | } 101 | 102 | if v := cfg.Get(SessionDurationEnvVarKeyName); v != "" { 103 | sessionDuration, err := gtime.ParseDuration(v) 104 | if err == nil { 105 | settings.SessionDuration = &sessionDuration 106 | } else { 107 | backend.Logger.Error("could not parse env variable", "var", SessionDurationEnvVarKeyName) 108 | } 109 | } 110 | 111 | if v := cfg.Get(ListMetricsPageLimitKeyName); v != "" { 112 | listMetricsPageLimit, err := strconv.Atoi(v) 113 | if err == nil { 114 | settings.ListMetricsPageLimit = listMetricsPageLimit 115 | } else { 116 | backend.Logger.Error("could not parse context variable", "var", ListMetricsPageLimitKeyName) 117 | } 118 | hasSettings = true 119 | } 120 | 121 | if v := cfg.Get(proxy.PluginSecureSocksProxyEnabled); v != "" { 122 | secureSocksDSProxyEnabled, err := strconv.ParseBool(v) 123 | if err == nil { 124 | settings.SecureSocksDSProxyEnabled = secureSocksDSProxyEnabled 125 | } else { 126 | backend.Logger.Error("could not parse context variable", "var", proxy.PluginSecureSocksProxyEnabled) 127 | } 128 | hasSettings = true 129 | } 130 | 131 | return settings, hasSettings 132 | } 133 | 134 | // ReadAuthSettingsFromEnvironmentVariables gets the Grafana auth settings from the environment variables 135 | // Deprecated: Use ReadAuthSettingsFromContext instead 136 | func ReadAuthSettingsFromEnvironmentVariables() *AuthSettings { 137 | authSettings := &AuthSettings{} 138 | allowedAuthProviders := []string{} 139 | providers := os.Getenv(AllowedAuthProvidersEnvVarKeyName) 140 | for _, authProvider := range strings.Split(providers, ",") { 141 | authProvider = strings.TrimSpace(authProvider) 142 | if authProvider != "" { 143 | allowedAuthProviders = append(allowedAuthProviders, authProvider) 144 | } 145 | } 146 | 147 | if len(allowedAuthProviders) == 0 { 148 | allowedAuthProviders = []string{"default", "keys", "credentials"} 149 | backend.Logger.Warn("could not find allowed auth providers. falling back to 'default, keys, credentials'") 150 | } 151 | authSettings.AllowedAuthProviders = allowedAuthProviders 152 | 153 | assumeRoleEnabledString := os.Getenv(AssumeRoleEnabledEnvVarKeyName) 154 | if len(assumeRoleEnabledString) == 0 { 155 | backend.Logger.Warn("environment variable missing. falling back to enable assume role", "var", AssumeRoleEnabledEnvVarKeyName) 156 | assumeRoleEnabledString = "true" 157 | } 158 | 159 | var err error 160 | authSettings.AssumeRoleEnabled, err = strconv.ParseBool(assumeRoleEnabledString) 161 | if err != nil { 162 | backend.Logger.Error("could not parse env variable", "var", AssumeRoleEnabledEnvVarKeyName) 163 | authSettings.AssumeRoleEnabled = defaultAssumeRoleEnabled 164 | } 165 | 166 | authSettings.ExternalID = os.Getenv(GrafanaAssumeRoleExternalIdKeyName) 167 | 168 | listMetricsPageLimitString := os.Getenv(ListMetricsPageLimitKeyName) 169 | if len(listMetricsPageLimitString) == 0 { 170 | backend.Logger.Warn("environment variable missing. falling back to default page limit", "var", ListMetricsPageLimitKeyName) 171 | listMetricsPageLimitString = "500" 172 | } 173 | 174 | authSettings.ListMetricsPageLimit, err = strconv.Atoi(listMetricsPageLimitString) 175 | if err != nil { 176 | backend.Logger.Error("could not parse env variable", "var", ListMetricsPageLimitKeyName) 177 | authSettings.ListMetricsPageLimit = defaultListMetricsPageLimit 178 | } 179 | 180 | sessionDurationString := os.Getenv(SessionDurationEnvVarKeyName) 181 | if sessionDurationString != "" { 182 | sessionDuration, err := gtime.ParseDuration(sessionDurationString) 183 | if err != nil { 184 | backend.Logger.Error("could not parse env variable", "var", SessionDurationEnvVarKeyName) 185 | } else { 186 | authSettings.SessionDuration = &sessionDuration 187 | } 188 | } 189 | 190 | proxyEnabledString := os.Getenv(proxy.PluginSecureSocksProxyEnabled) 191 | if len(proxyEnabledString) == 0 { 192 | backend.Logger.Warn("environment variable missing. falling back to proxy disabled", "var", proxy.PluginSecureSocksProxyEnabled) 193 | proxyEnabledString = "false" 194 | } 195 | 196 | authSettings.SecureSocksDSProxyEnabled, err = strconv.ParseBool(proxyEnabledString) 197 | if err != nil { 198 | backend.Logger.Error("could not parse env variable", "var", proxy.PluginSecureSocksProxyEnabled) 199 | authSettings.SecureSocksDSProxyEnabled = defaultSecureSocksDSProxyEnabled 200 | } 201 | 202 | return authSettings 203 | } 204 | 205 | // ReadSigV4Settings gets the SigV4 settings from the context if its available 206 | func ReadSigV4Settings(ctx context.Context) *SigV4Settings { 207 | cfg := backend.GrafanaConfigFromContext(ctx) 208 | return &SigV4Settings{ 209 | Enabled: cfg.Get(SigV4AuthEnabledEnvVarKeyName) == "true", 210 | VerboseLogging: cfg.Get(SigV4VerboseLoggingEnvVarKeyName) == "true", 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /pkg/awsds/authSettings_test.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestReadAuthSettingsFromContext(t *testing.T) { 16 | tcs := []struct { 17 | name string 18 | cfg *backend.GrafanaCfg 19 | expectedSettings *AuthSettings 20 | expectedHasSettings bool 21 | }{ 22 | { 23 | name: "nil config", 24 | cfg: nil, 25 | expectedSettings: defaultAuthSettings(), 26 | expectedHasSettings: false, 27 | }, 28 | { 29 | name: "empty config", 30 | cfg: &backend.GrafanaCfg{}, 31 | expectedSettings: defaultAuthSettings(), 32 | expectedHasSettings: false, 33 | }, 34 | { 35 | name: "nil config map", 36 | cfg: backend.NewGrafanaCfg(nil), 37 | expectedSettings: defaultAuthSettings(), 38 | expectedHasSettings: false, 39 | }, 40 | { 41 | name: "empty config map", 42 | cfg: backend.NewGrafanaCfg(make(map[string]string)), 43 | expectedSettings: defaultAuthSettings(), 44 | expectedHasSettings: false, 45 | }, 46 | { 47 | name: "aws settings in config", 48 | cfg: backend.NewGrafanaCfg(map[string]string{ 49 | AllowedAuthProvidersEnvVarKeyName: "foo , bar,baz", 50 | AssumeRoleEnabledEnvVarKeyName: "false", 51 | GrafanaAssumeRoleExternalIdKeyName: "mock_id", 52 | ListMetricsPageLimitKeyName: "50", 53 | proxy.PluginSecureSocksProxyEnabled: "true", 54 | }), 55 | expectedSettings: &AuthSettings{ 56 | AllowedAuthProviders: []string{"foo", "bar", "baz"}, 57 | AssumeRoleEnabled: false, 58 | ExternalID: "mock_id", 59 | ListMetricsPageLimit: 50, 60 | SessionDuration: &stscreds.DefaultDuration, 61 | SecureSocksDSProxyEnabled: true, 62 | }, 63 | expectedHasSettings: true, 64 | }, 65 | } 66 | for _, tc := range tcs { 67 | t.Run(tc.name, func(t *testing.T) { 68 | ctx := backend.WithGrafanaConfig(context.Background(), tc.cfg) 69 | settings, hasSettings := ReadAuthSettingsFromContext(ctx) 70 | 71 | require.Equal(t, tc.expectedHasSettings, hasSettings) 72 | require.Equal(t, tc.expectedSettings, settings) 73 | }) 74 | } 75 | } 76 | 77 | func TestReadAuthSettings(t *testing.T) { 78 | originalExternalId := os.Getenv(GrafanaAssumeRoleExternalIdKeyName) 79 | os.Setenv(GrafanaAssumeRoleExternalIdKeyName, "env_id") 80 | defer func() { 81 | os.Setenv(GrafanaAssumeRoleExternalIdKeyName, originalExternalId) 82 | }() 83 | 84 | ctxDuration := 10 * time.Minute 85 | envDuration := 20 * time.Minute 86 | expectedSessionContextSettings := &AuthSettings{ 87 | AllowedAuthProviders: []string{"foo", "bar", "baz"}, 88 | AssumeRoleEnabled: false, 89 | SessionDuration: &ctxDuration, 90 | ExternalID: "mock_id", 91 | ListMetricsPageLimit: 50, 92 | SecureSocksDSProxyEnabled: true, 93 | } 94 | 95 | expectedSessionEnvSettings := &AuthSettings{ 96 | AllowedAuthProviders: []string{"default", "keys", "credentials"}, 97 | AssumeRoleEnabled: true, 98 | SessionDuration: &envDuration, 99 | ExternalID: "env_id", 100 | ListMetricsPageLimit: 30, 101 | SecureSocksDSProxyEnabled: false, 102 | } 103 | 104 | require.NoError(t, os.Setenv(ListMetricsPageLimitKeyName, "30")) 105 | require.NoError(t, os.Setenv(SessionDurationEnvVarKeyName, "20m")) 106 | require.NoError(t, os.Setenv(proxy.PluginSecureSocksProxyEnabled, "false")) 107 | defer unsetEnvironmentVariables() 108 | 109 | tcs := []struct { 110 | name string 111 | cfg *backend.GrafanaCfg 112 | expectedSettings *AuthSettings 113 | }{ 114 | { 115 | name: "read from env if config is nil", 116 | cfg: nil, 117 | expectedSettings: expectedSessionEnvSettings, 118 | }, 119 | { 120 | name: "read from env if config is empty", 121 | cfg: &backend.GrafanaCfg{}, 122 | expectedSettings: expectedSessionEnvSettings, 123 | }, 124 | { 125 | name: "read from env if config map is nil", 126 | cfg: backend.NewGrafanaCfg(nil), 127 | expectedSettings: expectedSessionEnvSettings, 128 | }, 129 | { 130 | name: "read from env if config map is empty", 131 | cfg: backend.NewGrafanaCfg(make(map[string]string)), 132 | expectedSettings: expectedSessionEnvSettings, 133 | }, 134 | { 135 | name: "read from context", 136 | cfg: backend.NewGrafanaCfg(map[string]string{ 137 | AllowedAuthProvidersEnvVarKeyName: "foo , bar,baz", 138 | AssumeRoleEnabledEnvVarKeyName: "false", 139 | SessionDurationEnvVarKeyName: "10m", 140 | GrafanaAssumeRoleExternalIdKeyName: "mock_id", 141 | ListMetricsPageLimitKeyName: "50", 142 | proxy.PluginSecureSocksProxyEnabled: "true", 143 | }), 144 | expectedSettings: expectedSessionContextSettings, 145 | }, 146 | } 147 | 148 | for _, tc := range tcs { 149 | t.Run(tc.name, func(t *testing.T) { 150 | ctx := backend.WithGrafanaConfig(context.Background(), tc.cfg) 151 | settings := ReadAuthSettings(ctx) 152 | 153 | require.Equal(t, tc.expectedSettings, settings) 154 | }) 155 | } 156 | } 157 | 158 | func TestReadSigV4Settings(t *testing.T) { 159 | tcs := []struct { 160 | name string 161 | cfg *backend.GrafanaCfg 162 | expectedSettings *SigV4Settings 163 | }{ 164 | { 165 | name: "empty config map", 166 | cfg: backend.NewGrafanaCfg(make(map[string]string)), 167 | expectedSettings: &SigV4Settings{}, 168 | }, 169 | { 170 | name: "aws settings in config", 171 | cfg: backend.NewGrafanaCfg(map[string]string{ 172 | SigV4AuthEnabledEnvVarKeyName: "true", 173 | SigV4VerboseLoggingEnvVarKeyName: "true", 174 | }), 175 | expectedSettings: &SigV4Settings{ 176 | Enabled: true, 177 | VerboseLogging: true, 178 | }, 179 | }, 180 | } 181 | for _, tc := range tcs { 182 | t.Run(tc.name, func(t *testing.T) { 183 | ctx := backend.WithGrafanaConfig(context.Background(), tc.cfg) 184 | settings := ReadSigV4Settings(ctx) 185 | 186 | require.Equal(t, tc.expectedSettings, settings) 187 | }) 188 | } 189 | } 190 | 191 | func unsetEnvironmentVariables() { 192 | os.Unsetenv(AllowedAuthProvidersEnvVarKeyName) 193 | os.Unsetenv(AssumeRoleEnabledEnvVarKeyName) 194 | os.Unsetenv(SessionDurationEnvVarKeyName) 195 | os.Unsetenv(ListMetricsPageLimitKeyName) 196 | } 197 | -------------------------------------------------------------------------------- /pkg/awsds/query.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func startQuery(ctx context.Context, db AsyncDB, query *AsyncQuery) (string, error) { 9 | if db == nil { 10 | return "", fmt.Errorf("async handler not defined") 11 | } 12 | 13 | found, queryID, err := db.GetQueryID(ctx, query.RawSQL) 14 | if found || err != nil { 15 | return queryID, err 16 | } 17 | 18 | return db.StartQuery(ctx, query.RawSQL) 19 | } 20 | 21 | func queryStatus(ctx context.Context, db AsyncDB, query *AsyncQuery) (QueryStatus, error) { 22 | if db == nil { 23 | return QueryUnknown, fmt.Errorf("async handler not defined") 24 | } 25 | return db.QueryStatus(ctx, query.QueryID) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/awsds/sessions.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/grafana/grafana-plugin-sdk-go/backend" 14 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 15 | 16 | "github.com/aws/aws-sdk-go/aws" 17 | "github.com/aws/aws-sdk-go/aws/credentials" 18 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 19 | "github.com/aws/aws-sdk-go/aws/defaults" 20 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 21 | "github.com/aws/aws-sdk-go/aws/endpoints" 22 | "github.com/aws/aws-sdk-go/aws/request" 23 | "github.com/aws/aws-sdk-go/aws/session" 24 | 25 | awsV2 "github.com/aws/aws-sdk-go-v2/aws" 26 | ) 27 | 28 | const ( 29 | // awsTempCredsAccessKey and awsTempCredsSecretKey are the files containing the 30 | awsTempCredsAccessKey = "/tmp/aws.credentials/access-key-id" 31 | awsTempCredsSecretKey = "/tmp/aws.credentials/secret-access-key" 32 | ) 33 | 34 | type envelope struct { 35 | session *session.Session 36 | expiration time.Time 37 | } 38 | 39 | // SessionCache cache sessions for a while 40 | type SessionCache struct { 41 | sessCache map[string]envelope 42 | sessCacheLock sync.RWMutex 43 | } 44 | 45 | // NewSessionCache creates a new session cache using the default settings loaded from environment variables 46 | func NewSessionCache() *SessionCache { 47 | return &SessionCache{ 48 | sessCache: map[string]envelope{}, 49 | } 50 | } 51 | 52 | const ( 53 | // CredentialsPath is the path to the shared credentials file in the instance for the aws/aws-sdk 54 | // if empty string, the path is ~/.aws/credentials 55 | CredentialsPath = "" 56 | 57 | // ProfileName is the profile containing credentials for GrafanaAssumeRole auth type in the shared credentials file 58 | ProfileName = "assume_role_credentials" 59 | ) 60 | 61 | // Session factory. 62 | // Stubbable by tests. 63 | // 64 | //nolint:gocritic 65 | var newSession = func(cfgs ...*aws.Config) (*session.Session, error) { 66 | s, err := session.NewSession(cfgs...) 67 | if err != nil { 68 | return nil, errorsource.DownstreamError(err, false) 69 | } 70 | return s, nil 71 | } 72 | 73 | // STS credentials factory. 74 | // Stubbable by tests. 75 | // 76 | //nolint:gocritic 77 | var newSTSCredentials = stscreds.NewCredentials 78 | 79 | // EC2Metadata service factory. 80 | // Stubbable by tests. 81 | // 82 | //nolint:gocritic 83 | var newEC2Metadata = ec2metadata.New 84 | 85 | // EC2 + ECS role credentials factory. 86 | // Stubbable by tests. 87 | var newRemoteCredentials = func(sess *session.Session) *credentials.Credentials { 88 | return credentials.NewCredentials(defaults.RemoteCredProvider(*sess.Config, sess.Handlers)) 89 | } 90 | 91 | type GetSessionConfig struct { 92 | Settings AWSDatasourceSettings 93 | HTTPClient *http.Client 94 | UserAgentName *string 95 | } 96 | 97 | type SessionConfig struct { 98 | Settings AWSDatasourceSettings 99 | HTTPClient *http.Client 100 | UserAgentName *string 101 | AuthSettings *AuthSettings 102 | } 103 | 104 | func isOptInRegion(region string) bool { 105 | // Opt-in region from https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions 106 | regions := map[string]bool{ 107 | "af-south-1": true, 108 | "ap-east-1": true, 109 | "ap-south-2": true, 110 | "ap-southeast-3": true, 111 | "ap-southeast-4": true, 112 | "ca-west-1": true, 113 | "eu-central-2": true, 114 | "eu-south-1": true, 115 | "eu-south-2": true, 116 | "il-central-1": true, 117 | "me-central-1": true, 118 | "me-south-1": true, 119 | // The rest of regions will return false 120 | } 121 | return regions[region] 122 | } 123 | 124 | // Deprecated: use GetSessionWithAuthSettings instead 125 | func (sc *SessionCache) GetSession(c SessionConfig) (*session.Session, error) { 126 | if c.Settings.Region == "" && c.Settings.DefaultRegion != "" { 127 | // DefaultRegion is deprecated, Region should be used instead 128 | c.Settings.Region = c.Settings.DefaultRegion 129 | } 130 | 131 | // If the datasource calling GetSession is getting the settings from the contexts, they'll pass 132 | // the values through AuthSettings. Otherwise, we need to get them from the env variables. 133 | if c.AuthSettings == nil { 134 | c.AuthSettings = ReadAuthSettingsFromEnvironmentVariables() 135 | } 136 | 137 | authTypeAllowed := false 138 | for _, provider := range c.AuthSettings.AllowedAuthProviders { 139 | if provider == c.Settings.AuthType.String() { 140 | authTypeAllowed = true 141 | break 142 | } 143 | } 144 | 145 | if !authTypeAllowed { 146 | // user error, but mark as downstream 147 | return nil, errorsource.DownstreamError(fmt.Errorf("attempting to use an auth type that is not allowed: %q", c.Settings.AuthType.String()), false) 148 | } 149 | 150 | if c.Settings.AssumeRoleARN != "" && !c.AuthSettings.AssumeRoleEnabled { 151 | // user error, but mark as downstream 152 | return nil, errorsource.DownstreamError(fmt.Errorf("attempting to use assume role (ARN) which is disabled in grafana.ini"), false) 153 | } 154 | 155 | // Hash the settings to use as a cache key 156 | b := strings.Builder{} 157 | for i, s := range []string{ 158 | c.Settings.AuthType.String(), c.Settings.AccessKey, c.Settings.SecretKey, c.Settings.Profile, c.Settings.AssumeRoleARN, c.Settings.Region, c.Settings.Endpoint, 159 | } { 160 | if i != 0 { 161 | b.WriteString(":") 162 | } 163 | b.WriteString(strings.ReplaceAll(s, ":", `\:`)) 164 | } 165 | 166 | hashedSettings := sha256.Sum256([]byte(b.String())) 167 | cacheKey := fmt.Sprintf("%v", hashedSettings) 168 | 169 | // Check if we have a valid session in the cache, if so return it 170 | sc.sessCacheLock.RLock() 171 | if env, ok := sc.sessCache[cacheKey]; ok { 172 | if env.expiration.After(time.Now().UTC()) { 173 | sc.sessCacheLock.RUnlock() 174 | return env.session, nil 175 | } 176 | } 177 | sc.sessCacheLock.RUnlock() 178 | 179 | cfgs := []*aws.Config{ 180 | { 181 | CredentialsChainVerboseErrors: aws.Bool(true), 182 | HTTPClient: c.HTTPClient, 183 | }, 184 | } 185 | 186 | var regionCfg *aws.Config 187 | if c.Settings.Region == defaultRegion { 188 | backend.Logger.Warn("Region is set to \"default\", which is unsupported") 189 | c.Settings.Region = "" 190 | } 191 | if c.Settings.Region != "" { 192 | if c.Settings.AssumeRoleARN != "" && c.AuthSettings.AssumeRoleEnabled && isOptInRegion(c.Settings.Region) { 193 | // When assuming a role, the real region is set later in a new session 194 | // so we use a well-known region here (not opt-in) to obtain valid credentials 195 | regionCfg = &aws.Config{Region: aws.String("us-east-1")} 196 | 197 | // set regional endpoint flag to obtain credentials that can be used in opt-in regions as well 198 | optInRegionCfg := &aws.Config{STSRegionalEndpoint: endpoints.RegionalSTSEndpoint} 199 | 200 | cfgs = append(cfgs, regionCfg, optInRegionCfg) 201 | } else { 202 | regionCfg = &aws.Config{Region: aws.String(c.Settings.Region)} 203 | cfgs = append(cfgs, regionCfg) 204 | } 205 | } 206 | 207 | switch c.Settings.AuthType { 208 | case AuthTypeSharedCreds: 209 | backend.Logger.Debug("Authenticating towards AWS with shared credentials", "profile", c.Settings.Profile, 210 | "region", c.Settings.Region) 211 | cfgs = append(cfgs, &aws.Config{ 212 | Credentials: credentials.NewSharedCredentials(CredentialsPath, c.Settings.Profile), 213 | }) 214 | case AuthTypeKeys: 215 | backend.Logger.Debug("Authenticating towards AWS with an access key pair", "region", c.Settings.Region) 216 | cfgs = append(cfgs, &aws.Config{ 217 | Credentials: credentials.NewStaticCredentials(c.Settings.AccessKey, c.Settings.SecretKey, c.Settings.SessionToken), 218 | }) 219 | case AuthTypeDefault: 220 | backend.Logger.Debug("Authenticating towards AWS with default SDK method", "region", c.Settings.Region) 221 | case AuthTypeEC2IAMRole: 222 | backend.Logger.Debug("Authenticating towards AWS with IAM Role", "region", c.Settings.Region) 223 | sess, err := newSession(cfgs...) 224 | if err != nil { 225 | return nil, err 226 | } 227 | cfgs = append(cfgs, &aws.Config{Credentials: newRemoteCredentials(sess)}) 228 | case AuthTypeGrafanaAssumeRole: 229 | backend.Logger.Debug("Authenticating towards AWS with Grafana Assume Role", "region", c.Settings.Region) 230 | accessKey, keyErr := os.ReadFile(awsTempCredsAccessKey) 231 | secretKey, secretErr := os.ReadFile(awsTempCredsSecretKey) 232 | if keyErr == nil && secretErr == nil { 233 | cfgs = append(cfgs, &aws.Config{ 234 | Credentials: credentials.NewStaticCredentials(string(accessKey), string(secretKey), ""), 235 | }) 236 | // if we don't find the files assume it's running single tenant and use the credentials file 237 | } else { 238 | cfgs = append(cfgs, &aws.Config{ 239 | Credentials: credentials.NewSharedCredentials(CredentialsPath, ProfileName), 240 | }) 241 | } 242 | default: 243 | return nil, fmt.Errorf("unrecognized authType: %d", c.Settings.AuthType) 244 | } 245 | 246 | duration := stscreds.DefaultDuration 247 | if c.AuthSettings.SessionDuration != nil { 248 | duration = *c.AuthSettings.SessionDuration 249 | } 250 | expiration := time.Now().UTC().Add(duration) 251 | 252 | if c.Settings.Endpoint != "" { 253 | cfgs = append(cfgs, &aws.Config{Endpoint: aws.String(c.Settings.Endpoint)}) 254 | } 255 | 256 | if c.Settings.AssumeRoleARN != "" && c.AuthSettings.AssumeRoleEnabled { 257 | // We should assume a role in AWS 258 | backend.Logger.Debug("Trying to assume role in AWS", "arn", c.Settings.AssumeRoleARN) 259 | 260 | // If a FIPS endpoint is set, we need to use the FIPS STS endpoint 261 | if c.Settings.Endpoint != "" { 262 | var endpoint = aws.String(getSTSEndpoint(c.Settings.Endpoint)) 263 | cfgs = append(cfgs, &aws.Config{Endpoint: endpoint}) 264 | } 265 | 266 | sess, err := newSession(cfgs...) 267 | if err != nil { 268 | return nil, err 269 | } 270 | 271 | cfgs = []*aws.Config{ 272 | { 273 | CredentialsChainVerboseErrors: aws.Bool(true), 274 | }, 275 | { 276 | // The previous session is used to obtain STS Credentials 277 | Credentials: newSTSCredentials(sess, c.Settings.AssumeRoleARN, func(p *stscreds.AssumeRoleProvider) { 278 | // Not sure if this is necessary, overlaps with p.Duration and is undocumented 279 | p.Expiry.SetExpiration(expiration, 0) 280 | p.Duration = duration 281 | if c.Settings.AuthType == AuthTypeGrafanaAssumeRole { 282 | p.ExternalID = aws.String(c.AuthSettings.ExternalID) 283 | } else if c.Settings.ExternalID != "" { 284 | p.ExternalID = aws.String(c.Settings.ExternalID) 285 | } 286 | }), 287 | }, 288 | } 289 | 290 | if c.Settings.Region != "" { 291 | regionCfg = &aws.Config{Region: aws.String(c.Settings.Region)} 292 | cfgs = append(cfgs, regionCfg) 293 | } 294 | 295 | // If a FIPS endpoint is set, we need to set the endpoint on the returned session 296 | if isFIPSEndpoint(c.Settings.Endpoint) { 297 | cfgs = append(cfgs, &aws.Config{Endpoint: aws.String(c.Settings.Endpoint)}) 298 | } 299 | } 300 | 301 | sess, err := newSession(cfgs...) 302 | if err != nil { 303 | return nil, err 304 | } 305 | 306 | if c.UserAgentName != nil { 307 | sess.Handlers.Send.PushFront(func(r *request.Request) { 308 | r.HTTPRequest.Header.Set("User-Agent", GetUserAgentString(*c.UserAgentName)) 309 | }) 310 | } 311 | 312 | backend.Logger.Debug("Successfully created AWS session") 313 | 314 | sc.sessCacheLock.Lock() 315 | sc.sessCache[cacheKey] = envelope{ 316 | session: sess, 317 | expiration: expiration, 318 | } 319 | sc.sessCacheLock.Unlock() 320 | 321 | return sess, nil 322 | } 323 | 324 | // AuthSettings can be grabed from the datasource instance's context with ReadAuthSettingsFromContext 325 | func (sc *SessionCache) GetSessionWithAuthSettings(c GetSessionConfig, as AuthSettings) (*session.Session, error) { 326 | return sc.GetSession(SessionConfig{ 327 | Settings: c.Settings, 328 | HTTPClient: c.HTTPClient, 329 | UserAgentName: c.UserAgentName, 330 | AuthSettings: &as, 331 | }) 332 | } 333 | 334 | // getSTSEndpoint returns true if the set endpoint is a fips endpoint 335 | func isFIPSEndpoint(endpoint string) bool { 336 | return strings.Contains(endpoint, "fips") || 337 | strings.Contains(endpoint, "us-gov-east-1") || 338 | strings.Contains(endpoint, "us-gov-west-1") 339 | } 340 | 341 | // getSTSEndpoint checks if the set endpoint is a fips endpoint, and if so, returns the STS fips endpoint for the same region 342 | func getSTSEndpoint(endpoint string) string { 343 | if endpoint == "" { 344 | return "" 345 | } 346 | if strings.Contains(endpoint, "fips") { 347 | switch { 348 | case strings.Contains(endpoint, "us-east-1"): 349 | return "sts-fips.us-east-1.amazonaws.com" 350 | case strings.Contains(endpoint, "us-east-2"): 351 | return "sts-fips.us-east-2.amazonaws.com" 352 | case strings.Contains(endpoint, "us-west-1"): 353 | return "sts-fips.us-west-1.amazonaws.com" 354 | case strings.Contains(endpoint, "us-west-2"): 355 | return "sts-fips.us-west-2.amazonaws.com" 356 | } 357 | } 358 | 359 | if strings.Contains(endpoint, "us-gov-east-1") { 360 | return "sts.us-gov-east-1.amazonaws.com" 361 | } 362 | if strings.Contains(endpoint, "us-gov-west-1") { 363 | return "sts.us-gov-west-1.amazonaws.com" 364 | } 365 | return endpoint 366 | } 367 | 368 | // CredentialsProviderV2 provides a CredentialsProvider suitable for use with aws-sdk-go-v2, 369 | // to be used while migrating datasources. 370 | // Experimental: this works but is not thoroughly tested yet 371 | func (sc *SessionCache) CredentialsProviderV2(ctx context.Context, cfg GetSessionConfig) (awsV2.CredentialsProvider, error) { 372 | authSettings := ReadAuthSettings(ctx) 373 | sess, err := sc.GetSessionWithAuthSettings(cfg, *authSettings) 374 | if err != nil { 375 | return nil, err 376 | } 377 | return &SessionCredentialsProvider{sess}, nil 378 | 379 | } 380 | 381 | type SessionCredentialsProvider struct { 382 | session *session.Session 383 | } 384 | 385 | func (scp *SessionCredentialsProvider) Retrieve(_ context.Context) (awsV2.Credentials, error) { 386 | creds := awsV2.Credentials{} 387 | v1creds, err := scp.session.Config.Credentials.Get() 388 | if err != nil { 389 | return creds, err 390 | } 391 | creds.AccessKeyID = v1creds.AccessKeyID 392 | creds.SecretAccessKey = v1creds.SecretAccessKey 393 | creds.SessionToken = v1creds.SessionToken 394 | return creds, nil 395 | } 396 | -------------------------------------------------------------------------------- /pkg/awsds/sessions_test.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "reflect" 8 | "testing" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/client" 13 | "github.com/aws/aws-sdk-go/aws/credentials" 14 | "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" 15 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 16 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 17 | "github.com/aws/aws-sdk-go/aws/endpoints" 18 | "github.com/aws/aws-sdk-go/aws/request" 19 | "github.com/aws/aws-sdk-go/aws/session" 20 | "github.com/google/go-cmp/cmp" 21 | "github.com/google/go-cmp/cmp/cmpopts" 22 | "github.com/stretchr/testify/assert" 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | // Test cloudWatchExecutor.newSession with assumption of IAM role. 27 | func TestNewSession_AssumeRole(t *testing.T) { 28 | origNewSession := newSession 29 | origNewSTSCredentials := newSTSCredentials 30 | origNewEC2Metadata := newEC2Metadata 31 | t.Cleanup(func() { 32 | newSession = origNewSession 33 | newSTSCredentials = origNewSTSCredentials 34 | newEC2Metadata = origNewEC2Metadata 35 | }) 36 | newSession = func(cfgs ...*aws.Config) (*session.Session, error) { 37 | cfg := aws.Config{} 38 | cfg.MergeIn(cfgs...) 39 | return &session.Session{ 40 | Config: &cfg, 41 | }, nil 42 | } 43 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 44 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 45 | p := &stscreds.AssumeRoleProvider{ 46 | RoleARN: roleARN, 47 | } 48 | for _, o := range options { 49 | o(p) 50 | } 51 | 52 | return credentials.NewCredentials(p) 53 | } 54 | newEC2Metadata = func(p client.ConfigProvider, cfgs ...*aws.Config) *ec2metadata.EC2Metadata { 55 | return nil 56 | } 57 | duration := stscreds.DefaultDuration 58 | 59 | t.Run("Without external ID", func(t *testing.T) { 60 | const roleARN = "test" 61 | settings := AWSDatasourceSettings{ 62 | AssumeRoleARN: roleARN, 63 | } 64 | cache := NewSessionCache() 65 | sess, err := cache.GetSession(SessionConfig{ 66 | Settings: settings, 67 | AuthSettings: &AuthSettings{ 68 | AllowedAuthProviders: []string{"default"}, 69 | AssumeRoleEnabled: true, 70 | }, 71 | }) 72 | require.NoError(t, err) 73 | require.NotNil(t, sess) 74 | expCreds := credentials.NewCredentials(&stscreds.AssumeRoleProvider{ 75 | RoleARN: roleARN, 76 | Duration: duration, 77 | }) 78 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 79 | return true 80 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 81 | assert.Empty(t, diff) 82 | }) 83 | 84 | t.Run("With external ID", func(t *testing.T) { 85 | const roleARN = "test" 86 | const externalID = "external" 87 | settings := AWSDatasourceSettings{ 88 | AssumeRoleARN: roleARN, 89 | ExternalID: externalID, 90 | } 91 | cache := NewSessionCache() 92 | sess, err := cache.GetSession(SessionConfig{ 93 | Settings: settings, 94 | AuthSettings: &AuthSettings{ 95 | AllowedAuthProviders: []string{"default"}, 96 | AssumeRoleEnabled: true, 97 | }, 98 | }) 99 | require.NoError(t, err) 100 | require.NotNil(t, sess) 101 | expCreds := credentials.NewCredentials(&stscreds.AssumeRoleProvider{ 102 | RoleARN: roleARN, 103 | ExternalID: aws.String(externalID), 104 | Duration: duration, 105 | }) 106 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 107 | return true 108 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 109 | assert.Empty(t, diff) 110 | }) 111 | 112 | t.Run("With custom duration", func(t *testing.T) { 113 | const roleARN = "test" 114 | settings := AWSDatasourceSettings{ 115 | AssumeRoleARN: roleARN, 116 | } 117 | expectedDuration := 20 * time.Minute 118 | 119 | t.Run("from config", func(t *testing.T) { 120 | cache := NewSessionCache() 121 | sess, err := cache.GetSession(SessionConfig{ 122 | Settings: settings, 123 | AuthSettings: &AuthSettings{ 124 | AllowedAuthProviders: []string{"default"}, 125 | AssumeRoleEnabled: true, 126 | SessionDuration: &expectedDuration, 127 | }, 128 | }) 129 | require.NoError(t, err) 130 | require.NotNil(t, sess) 131 | expCreds := credentials.NewCredentials(&stscreds.AssumeRoleProvider{ 132 | RoleARN: roleARN, 133 | Duration: expectedDuration, 134 | }) 135 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 136 | return true 137 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 138 | assert.Empty(t, diff) 139 | }) 140 | 141 | t.Run("from env variable", func(t *testing.T) { 142 | defer unsetEnvironmentVariables() 143 | require.NoError(t, os.Setenv(AllowedAuthProvidersEnvVarKeyName, "default")) 144 | require.NoError(t, os.Setenv(AssumeRoleEnabledEnvVarKeyName, "true")) 145 | require.NoError(t, os.Setenv(SessionDurationEnvVarKeyName, "20m")) 146 | cache := NewSessionCache() 147 | sess, err := cache.GetSession(SessionConfig{Settings: settings}) 148 | require.NoError(t, err) 149 | require.NotNil(t, sess) 150 | expCreds := credentials.NewCredentials(&stscreds.AssumeRoleProvider{ 151 | RoleARN: roleARN, 152 | Duration: expectedDuration, 153 | }) 154 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 155 | return true 156 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 157 | assert.Empty(t, diff) 158 | }) 159 | }) 160 | 161 | t.Run("Assume role not enabled", func(t *testing.T) { 162 | const roleARN = "test" 163 | settings := AWSDatasourceSettings{ 164 | AssumeRoleARN: roleARN, 165 | } 166 | 167 | t.Run("from config", func(t *testing.T) { 168 | cache := NewSessionCache() 169 | sess, err := cache.GetSession(SessionConfig{ 170 | Settings: settings, 171 | AuthSettings: &AuthSettings{ 172 | AllowedAuthProviders: []string{"default"}, 173 | AssumeRoleEnabled: false, 174 | }, 175 | }) 176 | require.Error(t, err) 177 | require.Nil(t, sess) 178 | expectedError := "attempting to use assume role (ARN) which is disabled in grafana.ini" 179 | assert.Equal(t, expectedError, err.Error()) 180 | }) 181 | 182 | t.Run("from env variable", func(t *testing.T) { 183 | defer unsetEnvironmentVariables() 184 | require.NoError(t, os.Setenv(AllowedAuthProvidersEnvVarKeyName, "default")) 185 | require.NoError(t, os.Setenv(AssumeRoleEnabledEnvVarKeyName, "false")) 186 | cache := NewSessionCache() 187 | sess, err := cache.GetSession(SessionConfig{Settings: settings}) 188 | require.Error(t, err) 189 | require.Nil(t, sess) 190 | expectedError := "attempting to use assume role (ARN) which is disabled in grafana.ini" 191 | assert.Equal(t, expectedError, err.Error()) 192 | }) 193 | }) 194 | 195 | t.Run("Assume role is enabled when AssumeRoleEnabled env var is missing and no config set", func(t *testing.T) { 196 | defer unsetEnvironmentVariables() 197 | const roleARN = "test" 198 | settings := AWSDatasourceSettings{ 199 | AssumeRoleARN: roleARN, 200 | } 201 | require.NoError(t, os.Setenv(AllowedAuthProvidersEnvVarKeyName, "default")) 202 | cache := NewSessionCache() 203 | sess, err := cache.GetSession(SessionConfig{Settings: settings}) 204 | require.NoError(t, err) 205 | require.NotNil(t, sess) 206 | expCreds := credentials.NewCredentials(&stscreds.AssumeRoleProvider{ 207 | RoleARN: roleARN, 208 | Duration: duration, 209 | }) 210 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 211 | return true 212 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 213 | assert.Empty(t, diff) 214 | }) 215 | 216 | t.Run("Assume role is enabled with an opt-in region", func(t *testing.T) { 217 | fakeNewSTSCredentials := newSTSCredentials 218 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 219 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 220 | sess := c.(*session.Session) 221 | // Verify that we are using the well-known region 222 | assert.Equal(t, "us-east-1", *sess.Config.Region) 223 | // verify that we're using regional sts endpoint 224 | assert.Equal(t, endpoints.RegionalSTSEndpoint, sess.Config.STSRegionalEndpoint) 225 | return fakeNewSTSCredentials(c, roleARN, options...) 226 | } 227 | settings := AWSDatasourceSettings{ 228 | AssumeRoleARN: "test", 229 | Region: "me-south-1", 230 | } 231 | cache := NewSessionCache() 232 | sess, err := cache.GetSession(SessionConfig{ 233 | Settings: settings, 234 | AuthSettings: &AuthSettings{ 235 | AllowedAuthProviders: []string{"default"}, 236 | AssumeRoleEnabled: true, 237 | }, 238 | }) 239 | newSTSCredentials = fakeNewSTSCredentials 240 | 241 | require.NoError(t, err) 242 | require.NotNil(t, sess) 243 | assert.Equal(t, "me-south-1", *sess.Config.Region) 244 | }) 245 | 246 | t.Run("Assume role is enabled with a gov region", func(t *testing.T) { 247 | fakeNewSTSCredentials := newSTSCredentials 248 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 249 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 250 | sess := c.(*session.Session) 251 | // Verify that we are using the well-known region 252 | assert.Equal(t, "us-gov-east-1", *sess.Config.Region) 253 | return fakeNewSTSCredentials(c, roleARN, options...) 254 | } 255 | settings := AWSDatasourceSettings{ 256 | AssumeRoleARN: "test", 257 | Region: "us-gov-east-1", 258 | } 259 | cache := NewSessionCache() 260 | sess, err := cache.GetSession(SessionConfig{ 261 | Settings: settings, 262 | AuthSettings: &AuthSettings{ 263 | AllowedAuthProviders: []string{"default"}, 264 | AssumeRoleEnabled: true, 265 | }, 266 | }) 267 | newSTSCredentials = fakeNewSTSCredentials 268 | 269 | require.NoError(t, err) 270 | require.NotNil(t, sess) 271 | assert.Equal(t, "us-gov-east-1", *sess.Config.Region) 272 | }) 273 | 274 | t.Run("Assume role is enabled with a fips endpoint", func(t *testing.T) { 275 | fakeNewSTSCredentials := newSTSCredentials 276 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 277 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 278 | sess := c.(*session.Session) 279 | // Verify that we are using the correct sts endpoint 280 | assert.Equal(t, "sts-fips.us-east-1.amazonaws.com", *sess.Config.Endpoint) 281 | return fakeNewSTSCredentials(c, roleARN, options...) 282 | } 283 | settings := AWSDatasourceSettings{ 284 | AssumeRoleARN: "test", 285 | Region: "us-east-1", 286 | Endpoint: "athena-fips.us-east-1.amazonaws.com", 287 | } 288 | cache := NewSessionCache() 289 | sess, err := cache.GetSession(SessionConfig{ 290 | Settings: settings, 291 | AuthSettings: &AuthSettings{ 292 | AllowedAuthProviders: []string{"default"}, 293 | AssumeRoleEnabled: true, 294 | }, 295 | }) 296 | newSTSCredentials = fakeNewSTSCredentials 297 | 298 | require.NoError(t, err) 299 | require.NotNil(t, sess) 300 | // Verify that we use the endpoint from the settings 301 | assert.Equal(t, settings.Endpoint, *sess.Config.Endpoint) 302 | }) 303 | 304 | t.Run("Assume role is enabled with a non-fips endpoint", func(t *testing.T) { 305 | fakeNewSTSCredentials := newSTSCredentials 306 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 307 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 308 | sess := c.(*session.Session) 309 | // Verify that we are using the correct sts endpoint 310 | assert.Equal(t, "sts.eu-west-2.amazonaws.com", *sess.Config.Endpoint) 311 | return fakeNewSTSCredentials(c, roleARN, options...) 312 | } 313 | settings := AWSDatasourceSettings{ 314 | AssumeRoleARN: "test", 315 | Region: "eu-west-2", 316 | Endpoint: "sts.eu-west-2.amazonaws.com", 317 | } 318 | cache := NewSessionCache() 319 | sess, err := cache.GetSession(SessionConfig{ 320 | Settings: settings, 321 | AuthSettings: &AuthSettings{ 322 | AllowedAuthProviders: []string{"default"}, 323 | AssumeRoleEnabled: true, 324 | }, 325 | }) 326 | newSTSCredentials = fakeNewSTSCredentials 327 | 328 | require.NoError(t, err) 329 | require.NotNil(t, sess) 330 | // Verify that we don't use the endpoint from the settings 331 | assert.Nil(t, sess.Config.Endpoint) 332 | }) 333 | } 334 | 335 | func TestNewSession_fips(t *testing.T) { 336 | origNewSession := newSession 337 | t.Cleanup(func() { 338 | newSession = origNewSession 339 | }) 340 | 341 | newSession = func(cfgs ...*aws.Config) (*session.Session, error) { 342 | cfg := aws.Config{} 343 | cfg.MergeIn(cfgs...) 344 | return &session.Session{ 345 | Config: &cfg, 346 | }, nil 347 | } 348 | 349 | t.Run("non-assume auth sets the fips endpoint", func(t *testing.T) { 350 | settings := AWSDatasourceSettings{ 351 | AuthType: AuthTypeKeys, 352 | AccessKey: "foo", 353 | SecretKey: "bar", 354 | Region: "us-east-1", 355 | Endpoint: "athena-fips.us-east-1.amazonaws.com", 356 | } 357 | cache := NewSessionCache() 358 | sess, err := cache.GetSession(SessionConfig{ 359 | Settings: settings, 360 | AuthSettings: &AuthSettings{ 361 | AllowedAuthProviders: []string{"keys"}, 362 | AssumeRoleEnabled: true, 363 | }, 364 | }) 365 | 366 | require.NoError(t, err) 367 | require.NotNil(t, sess) 368 | // Verify that we use the endpoint from the settings 369 | assert.Equal(t, settings.Endpoint, *sess.Config.Endpoint) 370 | }) 371 | } 372 | 373 | func TestNewSession_AllowedAuthProviders(t *testing.T) { 374 | t.Run("Not allowed auth type is used", func(t *testing.T) { 375 | settings := AWSDatasourceSettings{ 376 | AuthType: AuthTypeDefault, 377 | } 378 | cache := NewSessionCache() 379 | sess, err := cache.GetSession(SessionConfig{ 380 | Settings: settings, 381 | AuthSettings: &AuthSettings{ 382 | AllowedAuthProviders: []string{"key"}, 383 | }, 384 | }) 385 | require.Error(t, err) 386 | require.Nil(t, sess) 387 | assert.Equal(t, `attempting to use an auth type that is not allowed: "default"`, err.Error()) 388 | }) 389 | 390 | t.Run("Allowed auth type is used", func(t *testing.T) { 391 | settings := AWSDatasourceSettings{ 392 | AuthType: AuthTypeKeys, 393 | } 394 | cache := NewSessionCache() 395 | sess, err := cache.GetSession(SessionConfig{ 396 | Settings: settings, 397 | AuthSettings: &AuthSettings{ 398 | AllowedAuthProviders: []string{"keys"}, 399 | }, 400 | }) 401 | require.NoError(t, err) 402 | require.NotNil(t, sess) 403 | }) 404 | 405 | t.Run("Fallback is used when env variable and auth settings are missing", func(t *testing.T) { 406 | defaultAuthProviders := []AuthType{AuthTypeDefault, AuthTypeKeys, AuthTypeSharedCreds} 407 | for _, provider := range defaultAuthProviders { 408 | defer unsetEnvironmentVariables() 409 | settings := AWSDatasourceSettings{ 410 | AuthType: provider, 411 | } 412 | cache := NewSessionCache() 413 | sess, err := cache.GetSession(SessionConfig{Settings: settings}) 414 | require.NoError(t, err) 415 | require.NotNil(t, sess) 416 | } 417 | }) 418 | } 419 | 420 | func TestNewSession_GrafanaAssumeRole(t *testing.T) { 421 | origNewSTSCredentials := newSTSCredentials 422 | t.Cleanup(func() { 423 | newSTSCredentials = origNewSTSCredentials 424 | }) 425 | 426 | t.Run("externalID is passed to the session", func(t *testing.T) { 427 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 428 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 429 | p := &stscreds.AssumeRoleProvider{ 430 | RoleARN: roleARN, 431 | } 432 | for _, o := range options { 433 | o(p) 434 | } 435 | require.NotNil(t, p.ExternalID) 436 | 437 | assert.Equal(t, "pretendExternalId", *p.ExternalID) 438 | return credentials.NewCredentials(p) 439 | } 440 | 441 | t.Run("from config", func(t *testing.T) { 442 | cache := NewSessionCache() 443 | _, err := cache.GetSession(SessionConfig{ 444 | Settings: AWSDatasourceSettings{ 445 | AuthType: AuthTypeGrafanaAssumeRole, 446 | AssumeRoleARN: "test_arn", 447 | }, 448 | AuthSettings: &AuthSettings{ 449 | AllowedAuthProviders: []string{"grafana_assume_role"}, 450 | ExternalID: "pretendExternalId", 451 | AssumeRoleEnabled: true, 452 | }, 453 | }) 454 | require.NoError(t, err) 455 | }) 456 | 457 | t.Run("from env variable", func(t *testing.T) { 458 | originalExternalId := os.Getenv(GrafanaAssumeRoleExternalIdKeyName) 459 | require.NoError(t, os.Setenv(GrafanaAssumeRoleExternalIdKeyName, "pretendExternalId")) 460 | t.Cleanup(func() { 461 | os.Setenv(GrafanaAssumeRoleExternalIdKeyName, originalExternalId) 462 | unsetEnvironmentVariables() 463 | }) 464 | require.NoError(t, os.Setenv(AllowedAuthProvidersEnvVarKeyName, "grafana_assume_role")) 465 | require.NoError(t, os.Setenv(AssumeRoleEnabledEnvVarKeyName, "true")) 466 | 467 | cache := NewSessionCache() 468 | _, err := cache.GetSession(SessionConfig{ 469 | Settings: AWSDatasourceSettings{ 470 | AuthType: AuthTypeGrafanaAssumeRole, 471 | AssumeRoleARN: "test_arn", 472 | }, 473 | }) 474 | require.NoError(t, err) 475 | }) 476 | }) 477 | 478 | t.Run("roleARN is passed to the session", func(t *testing.T) { 479 | newSTSCredentials = func(c client.ConfigProvider, roleARN string, 480 | options ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 481 | p := &stscreds.AssumeRoleProvider{ 482 | RoleARN: roleARN, 483 | } 484 | for _, o := range options { 485 | o(p) 486 | } 487 | require.Equal(t, "test_arn", roleARN) 488 | return credentials.NewCredentials(p) 489 | } 490 | 491 | cache := NewSessionCache() 492 | _, err := cache.GetSession(SessionConfig{ 493 | Settings: AWSDatasourceSettings{ 494 | AuthType: AuthTypeGrafanaAssumeRole, 495 | AssumeRoleARN: "test_arn", 496 | }, 497 | AuthSettings: &AuthSettings{ 498 | AllowedAuthProviders: []string{"grafana_assume_role"}, 499 | AssumeRoleEnabled: true, 500 | }, 501 | }) 502 | 503 | require.NoError(t, err) 504 | }) 505 | } 506 | 507 | func TestNewSession_EC2IAMRole(t *testing.T) { 508 | newSession = func(cfgs ...*aws.Config) (*session.Session, error) { 509 | cfg := aws.Config{} 510 | cfg.MergeIn(cfgs...) 511 | return &session.Session{ 512 | Config: &cfg, 513 | }, nil 514 | } 515 | newEC2Metadata = func(p client.ConfigProvider, cfgs ...*aws.Config) *ec2metadata.EC2Metadata { 516 | return nil 517 | } 518 | newRemoteCredentials = func(_ *session.Session) *credentials.Credentials { 519 | return credentials.NewCredentials(&ec2rolecreds.EC2RoleProvider{Client: newEC2Metadata(nil), ExpiryWindow: stscreds.DefaultDuration}) 520 | } 521 | 522 | t.Run("Credentials are created", func(t *testing.T) { 523 | credentialCfgs := []*aws.Config{} 524 | newSession = func(cfgs ...*aws.Config) (*session.Session, error) { 525 | cfg := aws.Config{} 526 | cfg.MergeIn(cfgs...) 527 | credentialCfgs = append(credentialCfgs, &cfg) 528 | return &session.Session{ 529 | Config: &cfg, 530 | }, nil 531 | } 532 | settings := AWSDatasourceSettings{ 533 | AuthType: AuthTypeEC2IAMRole, 534 | Endpoint: "foo", 535 | } 536 | 537 | cache := NewSessionCache() 538 | sess, err := cache.GetSession(SessionConfig{Settings: settings, 539 | AuthSettings: &AuthSettings{ 540 | AllowedAuthProviders: []string{"ec2_iam_role"}, 541 | AssumeRoleEnabled: true, 542 | }}) 543 | require.NoError(t, err) 544 | require.NotNil(t, sess) 545 | 546 | expCreds := credentials.NewCredentials(&ec2rolecreds.EC2RoleProvider{ 547 | Client: newEC2Metadata(nil), ExpiryWindow: stscreds.DefaultDuration, 548 | }) 549 | diff := cmp.Diff(expCreds, sess.Config.Credentials, cmp.Exporter(func(_ reflect.Type) bool { 550 | return true 551 | }), cmpopts.IgnoreFields(stscreds.AssumeRoleProvider{}, "Expiry")) 552 | assert.Empty(t, diff) 553 | 554 | // Endpoint should be added to final session but not configuration session 555 | require.Equal(t, 2, len(credentialCfgs)) 556 | require.Nil(t, credentialCfgs[0].Endpoint) 557 | require.NotNil(t, credentialCfgs[1].Endpoint) 558 | require.NotNil(t, sess.Config.Endpoint) 559 | require.Equal(t, "foo", *sess.Config.Endpoint) 560 | }) 561 | } 562 | 563 | func TestWithUserAgent(t *testing.T) { 564 | cache := NewSessionCache() 565 | sess, err := cache.GetSession(SessionConfig{UserAgentName: aws.String("Athena"), 566 | AuthSettings: &AuthSettings{ 567 | AllowedAuthProviders: []string{"default"}, 568 | AssumeRoleEnabled: false, 569 | }, 570 | }) 571 | require.NoError(t, err) 572 | require.NotNil(t, sess) 573 | req := &request.Request{ 574 | HTTPRequest: httptest.NewRequest(http.MethodGet, "/upper?word=abc", nil), 575 | } 576 | sess.Handlers.Send.Run(req) 577 | 578 | res := req.HTTPRequest.Header.Get("User-Agent") 579 | assert.Contains(t, res, "Athena/dev") 580 | } 581 | 582 | func TestWithCustomHTTPClient(t *testing.T) { 583 | cache := NewSessionCache() 584 | sess, err := cache.GetSession(SessionConfig{ 585 | HTTPClient: &http.Client{Timeout: 123}, 586 | AuthSettings: &AuthSettings{ 587 | AllowedAuthProviders: []string{"default"}, 588 | AssumeRoleEnabled: false, 589 | }, 590 | }) 591 | require.NoError(t, err) 592 | require.NotNil(t, sess) 593 | assert.Equal(t, time.Duration(123), sess.Config.HTTPClient.Timeout) 594 | } 595 | 596 | func TestGetSessionWithAuthSettings(t *testing.T) { 597 | t.Run("it uses the passed in for auth settings", func(t *testing.T) { 598 | sessionConfig := GetSessionConfig{ 599 | Settings: AWSDatasourceSettings{ 600 | AuthType: AuthTypeKeys, 601 | AccessKey: "foo", 602 | SecretKey: "bar", 603 | }, 604 | } 605 | authSettings := AuthSettings{ 606 | AllowedAuthProviders: []string{"ec2_iam_role"}, 607 | } 608 | sessionCache := NewSessionCache() 609 | _, err := sessionCache.GetSessionWithAuthSettings(sessionConfig, authSettings) 610 | require.EqualError(t, err, "attempting to use an auth type that is not allowed: \"keys\"") 611 | }) 612 | } 613 | -------------------------------------------------------------------------------- /pkg/awsds/settings.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | ) 10 | 11 | const defaultRegion = "default" 12 | 13 | type AuthType int 14 | 15 | const ( 16 | AuthTypeDefault AuthType = iota 17 | AuthTypeSharedCreds 18 | AuthTypeKeys 19 | AuthTypeEC2IAMRole 20 | AuthTypeGrafanaAssumeRole //cloud only 21 | ) 22 | 23 | func (at *AuthType) String() string { 24 | switch *at { 25 | case AuthTypeDefault: 26 | return "default" 27 | case AuthTypeSharedCreds: 28 | return "credentials" 29 | case AuthTypeKeys: 30 | return "keys" 31 | case AuthTypeEC2IAMRole: 32 | return "ec2_iam_role" 33 | case AuthTypeGrafanaAssumeRole: 34 | return "grafana_assume_role" 35 | default: 36 | panic(fmt.Sprintf("Unrecognized auth type %d", at)) 37 | } 38 | } 39 | 40 | func ToAuthType(authType string) (AuthType, error) { 41 | switch authType { 42 | case "credentials", "sharedCreds": 43 | return AuthTypeSharedCreds, nil 44 | case "keys": 45 | return AuthTypeKeys, nil 46 | case "default": 47 | return AuthTypeDefault, nil 48 | case "ec2_iam_role": 49 | return AuthTypeEC2IAMRole, nil 50 | case "arn": 51 | return AuthTypeDefault, nil 52 | case "grafana_assume_role": 53 | return AuthTypeGrafanaAssumeRole, nil 54 | default: 55 | return AuthTypeDefault, fmt.Errorf("invalid auth type: %s", authType) 56 | } 57 | } 58 | 59 | // MarshalJSON marshals the enum as a quoted json string 60 | func (at *AuthType) MarshalJSON() ([]byte, error) { 61 | buffer := bytes.NewBufferString(`"`) 62 | buffer.WriteString(at.String()) 63 | buffer.WriteString(`"`) 64 | return buffer.Bytes(), nil 65 | } 66 | 67 | // UnmarshalJSON unmashals a quoted json string to the enum value 68 | func (at *AuthType) UnmarshalJSON(b []byte) error { 69 | var j string 70 | err := json.Unmarshal(b, &j) 71 | if err != nil { 72 | return err 73 | } 74 | switch j { 75 | case "credentials": // Old name 76 | fallthrough 77 | case "sharedCreds": 78 | *at = AuthTypeSharedCreds 79 | case "keys": 80 | *at = AuthTypeKeys 81 | case "ec2_iam_role": 82 | *at = AuthTypeEC2IAMRole 83 | case "grafana_assume_role": 84 | *at = AuthTypeGrafanaAssumeRole 85 | case "default": 86 | fallthrough 87 | default: 88 | *at = AuthTypeDefault // For old 'arn' option 89 | } 90 | return nil 91 | } 92 | 93 | // DatasourceSettings holds basic connection info 94 | type AWSDatasourceSettings struct { 95 | Profile string `json:"profile"` 96 | Region string `json:"region"` 97 | AuthType AuthType `json:"authType"` 98 | AssumeRoleARN string `json:"assumeRoleARN"` 99 | ExternalID string `json:"externalId"` 100 | 101 | // Override the client endpoint 102 | Endpoint string `json:"endpoint"` 103 | 104 | //go:deprecated Use Region instead 105 | DefaultRegion string `json:"defaultRegion"` 106 | 107 | // Loaded from DecryptedSecureJSONData (not the json object) 108 | AccessKey string `json:"-"` 109 | SecretKey string `json:"-"` 110 | SessionToken string `json:"-"` 111 | } 112 | 113 | // LoadSettings will read and validate Settings from the DataSourceConfg 114 | func (s *AWSDatasourceSettings) Load(config backend.DataSourceInstanceSettings) error { 115 | if len(config.JSONData) > 1 { 116 | if err := json.Unmarshal(config.JSONData, s); err != nil { 117 | return fmt.Errorf("could not unmarshal DatasourceSettings json: %w", err) 118 | } 119 | } 120 | 121 | if s.Region == defaultRegion || s.Region == "" { 122 | s.Region = s.DefaultRegion 123 | } 124 | 125 | if s.Profile == "" { 126 | s.Profile = config.Database // legacy support (only for cloudwatch?) 127 | } 128 | 129 | s.AccessKey = config.DecryptedSecureJSONData["accessKey"] 130 | s.SecretKey = config.DecryptedSecureJSONData["secretKey"] 131 | s.SessionToken = config.DecryptedSecureJSONData["sessionToken"] 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /pkg/awsds/settings_test.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // Test load settings from json 13 | func TestLoadSettings(t *testing.T) { 14 | settings := &AWSDatasourceSettings{ 15 | AuthType: AuthTypeKeys, 16 | DefaultRegion: "aaaa", 17 | } 18 | 19 | bytes, _ := json.Marshal(settings) 20 | copy := &AWSDatasourceSettings{} 21 | config := backend.DataSourceInstanceSettings{ 22 | DecryptedSecureJSONData: map[string]string{}, 23 | JSONData: bytes, 24 | } 25 | err := copy.Load(config) 26 | if err != nil { 27 | t.Fatalf("error reading config: %v", err) 28 | } 29 | 30 | assert.Empty(t, cmp.Diff(settings.AuthType, copy.AuthType)) 31 | assert.Empty(t, cmp.Diff(settings.DefaultRegion, copy.DefaultRegion)) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/awsds/types.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" 11 | "github.com/grafana/sqlds/v4" 12 | 13 | "github.com/aws/aws-sdk-go/aws/session" 14 | "github.com/grafana/grafana-plugin-sdk-go/backend" 15 | ) 16 | 17 | // AmazonSessionProvider will return a session (perhaps cached) for given region and settings 18 | type AmazonSessionProvider func(region string, s AWSDatasourceSettings) (*session.Session, error) 19 | 20 | // AuthSettings stores the AWS settings from Grafana 21 | type AuthSettings struct { 22 | AllowedAuthProviders []string 23 | AssumeRoleEnabled bool 24 | SessionDuration *time.Duration 25 | ExternalID string 26 | ListMetricsPageLimit int 27 | MultiTenantTempCredentials bool 28 | 29 | // necessary for a work around until https://github.com/grafana/grafana/issues/39089 is implemented 30 | SecureSocksDSProxyEnabled bool 31 | } 32 | 33 | // SigV4Settings stores the settings for SigV4 authentication 34 | type SigV4Settings struct { 35 | Enabled bool 36 | VerboseLogging bool 37 | } 38 | 39 | // QueryStatus represents the status of an async query 40 | type QueryStatus uint32 41 | 42 | const ( 43 | QueryUnknown QueryStatus = iota 44 | QuerySubmitted 45 | QueryRunning 46 | QueryFinished 47 | QueryCanceled 48 | QueryFailed 49 | ) 50 | 51 | type DownstreamErrorCause uint32 52 | 53 | const ( 54 | QueryFailedInternal DownstreamErrorCause = iota 55 | QueryFailedUser 56 | ) 57 | 58 | // QueryExecutionError error type can be returned from datasource's Execute or QueryStatus methods 59 | // this will mark the downstream cause in errorResponse.Status 60 | type QueryExecutionError struct { 61 | Err error 62 | Cause DownstreamErrorCause 63 | } 64 | 65 | func (e *QueryExecutionError) Error() string { 66 | if e.Err != nil { 67 | return e.Err.Error() 68 | } 69 | return "" 70 | } 71 | 72 | func (e *QueryExecutionError) Unwrap() error { 73 | return e.Err 74 | } 75 | 76 | func (qs QueryStatus) Finished() bool { 77 | return qs == QueryCanceled || qs == QueryFailed || qs == QueryFinished 78 | } 79 | 80 | func (qs QueryStatus) String() string { 81 | switch qs { 82 | case QuerySubmitted: 83 | return "submitted" 84 | case QueryRunning: 85 | return "running" 86 | case QueryFinished: 87 | return "finished" 88 | case QueryCanceled: 89 | return "canceled" 90 | case QueryFailed: 91 | return "failed" 92 | default: 93 | return "unknown" 94 | } 95 | } 96 | 97 | type QueryMeta struct { 98 | QueryFlow string `json:"queryFlow,omitempty"` 99 | } 100 | 101 | type AsyncQuery struct { 102 | sqlutil.Query 103 | QueryID string `json:"queryID,omitempty"` 104 | Meta QueryMeta `json:"meta,omitempty"` 105 | } 106 | 107 | // GetQuery returns a Query object given a backend.DataQuery using json.Unmarshal 108 | func GetQuery(query backend.DataQuery) (*AsyncQuery, error) { 109 | model := &AsyncQuery{} 110 | 111 | if err := json.Unmarshal(query.JSON, &model); err != nil { 112 | return nil, fmt.Errorf("%w: %v", sqlutil.ErrorJSON, err) 113 | } 114 | 115 | // Copy directly from the well typed query 116 | model.RefID = query.RefID 117 | model.Interval = query.Interval 118 | model.TimeRange = query.TimeRange 119 | model.MaxDataPoints = query.MaxDataPoints 120 | 121 | return &AsyncQuery{ 122 | Query: model.Query, 123 | QueryID: model.QueryID, 124 | Meta: model.Meta, 125 | }, nil 126 | } 127 | 128 | // AsyncDB represents an async SQL connection 129 | type AsyncDB interface { 130 | // DB generic methods 131 | driver.Conn 132 | Ping(ctx context.Context) error 133 | 134 | // Async flow 135 | StartQuery(ctx context.Context, query string, args ...interface{}) (string, error) 136 | GetQueryID(ctx context.Context, query string, args ...interface{}) (bool, string, error) 137 | QueryStatus(ctx context.Context, queryID string) (QueryStatus, error) 138 | CancelQuery(ctx context.Context, queryID string) error 139 | GetRows(ctx context.Context, queryID string) (driver.Rows, error) 140 | } 141 | 142 | // AsyncDriver extends the driver interface to also connect to async SQL datasources 143 | type AsyncDriver interface { 144 | sqlds.Driver 145 | GetAsyncDB(ctx context.Context, settings backend.DataSourceInstanceSettings, queryArgs json.RawMessage) (AsyncDB, error) 146 | } 147 | -------------------------------------------------------------------------------- /pkg/awsds/utils.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | "strconv" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/build" 12 | "github.com/grafana/grafana-plugin-sdk-go/data" 13 | ) 14 | 15 | // ShouldCacheQuery checks whether resp contains a running query, and returns false if it does 16 | func ShouldCacheQuery(resp *backend.QueryDataResponse) bool { 17 | if resp == nil { 18 | return true 19 | } 20 | 21 | shouldCache := true 22 | for _, response := range resp.Responses { 23 | for _, frame := range response.Frames { 24 | if frame.Meta != nil && frame.Meta.Custom != nil { 25 | // If the response doesn't contain a status, it isn't an async query 26 | meta, ok := frame.Meta.Custom.(map[string]interface{}) 27 | if !ok { 28 | continue 29 | } 30 | 31 | if meta["status"] == nil { 32 | continue 33 | } 34 | metaStatus, ok := meta["status"].(string) 35 | if !ok { 36 | continue 37 | } 38 | 39 | // we should not cache running queries 40 | if metaStatus == QueryRunning.String() || metaStatus == QuerySubmitted.String() { 41 | shouldCache = false 42 | break 43 | } 44 | } 45 | } 46 | } 47 | return shouldCache 48 | } 49 | 50 | // GetUserAgentString returns an agent that can be parsed in server logs 51 | func GetUserAgentString(name string) string { 52 | // Build info is set from compile time flags 53 | buildInfo, err := build.GetBuildInfo() 54 | if err != nil { 55 | buildInfo.Version = "dev" 56 | } 57 | 58 | grafanaVersion := os.Getenv("GF_VERSION") 59 | if grafanaVersion == "" { 60 | grafanaVersion = "?" 61 | } 62 | 63 | // Determine if running in an Amazon Managed Grafana environment 64 | _, amgEnv := os.LookupEnv("AMAZON_MANAGED_GRAFANA") 65 | 66 | return fmt.Sprintf("%s/%s (%s; %s;) %s/%s Grafana/%s AMG/%s", 67 | aws.SDKName, 68 | aws.SDKVersion, 69 | runtime.Version(), 70 | runtime.GOOS, 71 | name, 72 | buildInfo.Version, 73 | grafanaVersion, 74 | strconv.FormatBool(amgEnv)) 75 | } 76 | 77 | // getErrorFrameFromQuery returns a error frames with empty data and meta fields 78 | func getErrorFrameFromQuery(query *AsyncQuery) data.Frames { 79 | frames := data.Frames{} 80 | frame := data.NewFrame(query.RefID) 81 | frame.Meta = &data.FrameMeta{ 82 | ExecutedQueryString: query.RawSQL, 83 | } 84 | frames = append(frames, frame) 85 | return frames 86 | } 87 | -------------------------------------------------------------------------------- /pkg/awsds/utils_test.go: -------------------------------------------------------------------------------- 1 | package awsds 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/data" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestShouldCacheQuery(t *testing.T) { 12 | 13 | testcases := []struct { 14 | name string 15 | customMeta map[string]interface{} 16 | nilResponse bool 17 | shouldCache bool 18 | }{ 19 | { 20 | "sync query should cache", 21 | map[string]interface{}{"foo": "bar"}, 22 | false, 23 | true, 24 | }, 25 | { 26 | "starting async query should cache", 27 | map[string]interface{}{"status": "started"}, 28 | false, 29 | true, 30 | }, 31 | { 32 | "submitted async query should not cache", 33 | map[string]interface{}{"status": "submitted"}, 34 | false, 35 | false, 36 | }, 37 | { 38 | "running async query should not cache", 39 | map[string]interface{}{"status": "running"}, 40 | false, 41 | false, 42 | }, 43 | { 44 | "done async query should cache", 45 | map[string]interface{}{"status": "done"}, 46 | false, 47 | true, 48 | }, 49 | { 50 | "should handle nil response", 51 | nil, 52 | true, 53 | true, 54 | }, 55 | } 56 | for _, tc := range testcases { 57 | t.Run(tc.name, func(t *testing.T) { 58 | fakeResponse := &backend.QueryDataResponse{ 59 | Responses: backend.Responses{ 60 | "a": backend.DataResponse{ 61 | Frames: data.Frames{ 62 | &data.Frame{ 63 | Meta: &data.FrameMeta{Custom: tc.customMeta}, 64 | }, 65 | }, 66 | }, 67 | }, 68 | } 69 | if tc.nilResponse { 70 | fakeResponse = nil 71 | } 72 | res := ShouldCacheQuery(fakeResponse) 73 | assert.Equal(t, tc.shouldCache, res) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/cloudWatchConsts/metrics_test.go: -------------------------------------------------------------------------------- 1 | package cloudWatchConsts 2 | 3 | import ( 4 | "fmt" 5 | "maps" 6 | "slices" 7 | "sort" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | // test to check NamespaceMetricsMap metrics are sorted alphabetically 15 | func TestNamespaceMetricsAlphabetized(t *testing.T) { 16 | unsortedMetricNamespaces := namespacesWithUnsortedMetrics(NamespaceMetricsMap) 17 | if len(unsortedMetricNamespaces) != 0 { 18 | assert.Fail(t, "NamespaceMetricsMap is not sorted alphabetically. Please replace the printed services") 19 | printNamespacesThatNeedSorted(unsortedMetricNamespaces) 20 | } 21 | } 22 | 23 | func TestNamespaceMetricKeysAllHaveDimensions(t *testing.T) { 24 | namespaceMetricsKeys := slices.Collect(maps.Keys(NamespaceMetricsMap)) 25 | namespaceDimensionKeys := slices.Collect(maps.Keys(NamespaceDimensionKeysMap)) 26 | 27 | namespaceMetricsMissingKeys := findMetricKeysFromAMissingInB(namespaceDimensionKeys, namespaceMetricsKeys) 28 | 29 | if len(namespaceMetricsMissingKeys) != 0 { 30 | assert.Fail(t, "NamespaceMetricsMap is missing key(s) from NamespaceDimensionKeysMap.") 31 | fmt.Println(strings.Join(namespaceMetricsMissingKeys, "\n")) 32 | } 33 | } 34 | 35 | func TestNamespaceDimensionKeysAllHaveMetrics(t *testing.T) { 36 | namespaceMetricsKeys := slices.Collect(maps.Keys(NamespaceMetricsMap)) 37 | namespaceDimensionKeys := slices.Collect(maps.Keys(NamespaceDimensionKeysMap)) 38 | 39 | namespaceDimensionMissingKeys := findMetricKeysFromAMissingInB(namespaceMetricsKeys, namespaceDimensionKeys) 40 | 41 | if len(namespaceDimensionMissingKeys) != 0 { 42 | assert.Fail(t, "NamespaceDimensionKeysMap is missing key(s) from NamespaceMetricsMap.") 43 | fmt.Println(strings.Join(namespaceDimensionMissingKeys, "\n")) 44 | } 45 | } 46 | 47 | func printNamespacesThatNeedSorted(unsortedMetricNamespaces []string) { 48 | slices.Sort(unsortedMetricNamespaces) 49 | 50 | for _, namespace := range unsortedMetricNamespaces { 51 | metrics := NamespaceMetricsMap[namespace] 52 | slices.Sort(metrics) 53 | uniqMetrics := slices.Compact(metrics) 54 | fmt.Printf(" \"%s\": {\n", namespace) 55 | for _, metric := range uniqMetrics { 56 | fmt.Printf(" \"%s\",\n", metric) 57 | } 58 | fmt.Println(" },") 59 | } 60 | fmt.Println("}") 61 | } 62 | 63 | // namespacesWithUnsortedMetrics returns which namespaces have unsorted metrics 64 | func namespacesWithUnsortedMetrics(NamespaceMetricsMap map[string][]string) []string { 65 | // Extract keys from the map and sort them 66 | keys := slices.Collect(maps.Keys(NamespaceMetricsMap)) 67 | 68 | sort.Strings(keys) 69 | 70 | var unsortedNamespace []string 71 | // Check if metrics are sorted for each key 72 | for _, namespace := range keys { 73 | metrics := NamespaceMetricsMap[namespace] 74 | sortedMetrics := make([]string, len(metrics)) 75 | copy(sortedMetrics, metrics) 76 | sort.Strings(sortedMetrics) 77 | 78 | // Compare the sorted metrics with the original to find unsorted metrics 79 | for i, metric := range metrics { 80 | if metric != sortedMetrics[i] { 81 | unsortedNamespace = append(unsortedNamespace, namespace) 82 | break // Only report the first unsorted metric per key 83 | } 84 | } 85 | } 86 | 87 | return unsortedNamespace 88 | } 89 | 90 | func findMetricKeysFromAMissingInB(a []string, b []string) []string { 91 | var missingKeys []string 92 | 93 | for i := range a { 94 | if !slices.Contains(b, a[i]) { 95 | missingKeys = append(missingKeys, a[i]) 96 | } 97 | } 98 | 99 | return missingKeys 100 | } 101 | -------------------------------------------------------------------------------- /pkg/sigv4/sigv4.go: -------------------------------------------------------------------------------- 1 | package sigv4 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httputil" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/grafana/grafana-plugin-sdk-go/backend" 15 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 16 | 17 | "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/aws/credentials" 19 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 20 | "github.com/aws/aws-sdk-go/aws/defaults" 21 | "github.com/aws/aws-sdk-go/aws/session" 22 | v4 "github.com/aws/aws-sdk-go/aws/signer/v4" 23 | "github.com/aws/aws-sdk-go/private/protocol/rest" 24 | 25 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 26 | ) 27 | 28 | var ( 29 | signerCache sync.Map 30 | newStsCreds = stscreds.NewCredentials 31 | newV4Signer = v4.NewSigner 32 | ) 33 | 34 | type middleware struct { 35 | signer *v4.Signer 36 | config *Config 37 | next http.RoundTripper 38 | 39 | verboseMode bool 40 | } 41 | 42 | type Config struct { 43 | AuthType string 44 | 45 | Profile string 46 | 47 | Service string 48 | 49 | AccessKey string 50 | SecretKey string 51 | SessionToken string 52 | 53 | AssumeRoleARN string 54 | ExternalID string 55 | Region string 56 | } 57 | 58 | type Opts struct { 59 | VerboseMode bool 60 | } 61 | 62 | func (c Config) asSha256() (string, error) { 63 | h := sha256.New() 64 | _, err := h.Write([]byte(fmt.Sprintf("%v", c))) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | return fmt.Sprintf("%x", h.Sum(nil)), nil 70 | } 71 | 72 | // The RoundTripperFunc type is an adapter to allow the use of ordinary 73 | // functions as RoundTrippers. If f is a function with the appropriate 74 | // signature, RoundTripperFunc(f) is a RoundTripper that calls f. 75 | type RoundTripperFunc func(req *http.Request) (*http.Response, error) 76 | 77 | // RoundTrip implements the RoundTripper interface. 78 | func (rt RoundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { 79 | return rt(r) 80 | } 81 | 82 | // New instantiates a new signing middleware with an optional succeeding 83 | // middleware. The http.DefaultTransport will be used if nil 84 | // AuthSettings can be gotten from the datasource instance's context with awsds.ReadAuthSettingsFromContext 85 | func New(cfg *Config, authSettings awsds.AuthSettings, next http.RoundTripper, opts ...Opts) (http.RoundTripper, error) { 86 | var sigv4Opts Opts 87 | switch len(opts) { 88 | case 0: 89 | sigv4Opts = Opts{ 90 | VerboseMode: false, 91 | } 92 | case 1: 93 | sigv4Opts = opts[0] 94 | default: 95 | return nil, fmt.Errorf("only empty or one Opts is valid as an argument") 96 | } 97 | 98 | if err := validateConfig(cfg); err != nil { 99 | return nil, err 100 | } 101 | 102 | return RoundTripperFunc(func(r *http.Request) (*http.Response, error) { 103 | if next == nil { 104 | next = http.DefaultTransport 105 | } 106 | 107 | var signer *v4.Signer 108 | cached, cacheHit := cachedSigner(cfg) 109 | if cacheHit { 110 | signer = cached 111 | } else { 112 | var err error 113 | signer, err = createSigner(cfg, authSettings, sigv4Opts.VerboseMode) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | sha, err := cfg.asSha256() 119 | if err != nil { 120 | return nil, err 121 | } 122 | signerCache.Store(sha, signer) 123 | } 124 | 125 | m := &middleware{ 126 | config: cfg, 127 | next: next, 128 | signer: signer, 129 | verboseMode: sigv4Opts.VerboseMode, 130 | } 131 | 132 | return m.exec(r) 133 | }), nil 134 | } 135 | 136 | func (m *middleware) exec(origReq *http.Request) (*http.Response, error) { 137 | req, err := m.createSignedRequest(origReq) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | return m.next.RoundTrip(req) 143 | } 144 | 145 | func (m *middleware) createSignedRequest(origReq *http.Request) (*http.Request, error) { 146 | m.logRequest(origReq, "stage", "pre-signature") 147 | 148 | req, err := http.NewRequest(origReq.Method, origReq.URL.String(), origReq.Body) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | body := bytes.NewReader([]byte{}) 154 | if req.Body != nil { 155 | b, err := io.ReadAll(req.Body) 156 | if err != nil { 157 | return nil, err 158 | } 159 | body = bytes.NewReader(b) 160 | } 161 | 162 | if strings.Contains(req.URL.RawPath, "%2C") { 163 | req.URL.RawPath = rest.EscapePath(req.URL.RawPath, false) 164 | } 165 | 166 | _, err = m.signer.Sign(req, body, m.config.Service, m.config.Region, time.Now().UTC()) 167 | 168 | copyHeaderWithoutOverwrite(req.Header, origReq.Header) 169 | 170 | m.logRequest(req, "stage", "post-signature") 171 | 172 | return req, err 173 | } 174 | 175 | func cachedSigner(cfg *Config) (*v4.Signer, bool) { 176 | sha, err := cfg.asSha256() 177 | if err != nil { 178 | return nil, false 179 | } 180 | 181 | if cached, exists := signerCache.Load(sha); exists { 182 | return cached.(*v4.Signer), true 183 | } 184 | return nil, false 185 | } 186 | 187 | func createSigner(cfg *Config, authSettings awsds.AuthSettings, verboseMode bool) (*v4.Signer, error) { 188 | authType, err := awsds.ToAuthType(cfg.AuthType) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | authTypeAllowed := false 194 | for _, provider := range authSettings.AllowedAuthProviders { 195 | if provider == authType.String() { 196 | authTypeAllowed = true 197 | break 198 | } 199 | } 200 | 201 | if !authTypeAllowed { 202 | return nil, fmt.Errorf("attempting to use an auth type for SigV4 that is not allowed: %q", authType.String()) 203 | } 204 | 205 | if cfg.AssumeRoleARN != "" && !authSettings.AssumeRoleEnabled { 206 | return nil, fmt.Errorf("attempting to use assume role (ARN) for SigV4 which is not enabled") 207 | } 208 | 209 | var signerOpts = func(s *v4.Signer) { 210 | if verboseMode { 211 | s.Logger = awsLoggerAdapter{logger: backend.Logger} 212 | s.Debug = aws.LogDebugWithSigning 213 | } 214 | } 215 | 216 | var c *credentials.Credentials 217 | switch authType { 218 | case awsds.AuthTypeKeys: 219 | c = credentials.NewStaticCredentials(cfg.AccessKey, cfg.SecretKey, cfg.SessionToken) 220 | case awsds.AuthTypeSharedCreds: 221 | c = credentials.NewSharedCredentials("", cfg.Profile) 222 | case awsds.AuthTypeEC2IAMRole: 223 | s, err := session.NewSession(&aws.Config{ 224 | Region: aws.String(cfg.Region), 225 | }) 226 | if err != nil { 227 | return nil, err 228 | } 229 | c = credentials.NewCredentials(defaults.RemoteCredProvider(*s.Config, s.Handlers)) 230 | 231 | if cfg.AssumeRoleARN != "" { 232 | s, err = session.NewSession(&aws.Config{ 233 | CredentialsChainVerboseErrors: aws.Bool(true), 234 | Region: aws.String(cfg.Region), 235 | Credentials: c, 236 | }) 237 | if err != nil { 238 | return nil, err 239 | } 240 | return getAssumeRoleSigner(s, cfg, signerOpts) 241 | } 242 | 243 | return newV4Signer(c, signerOpts), nil 244 | case awsds.AuthTypeDefault: 245 | s, err := session.NewSession(&aws.Config{ 246 | Region: aws.String(cfg.Region), 247 | }) 248 | if err != nil { 249 | return nil, err 250 | } 251 | 252 | if cfg.AssumeRoleARN != "" { 253 | return getAssumeRoleSigner(s, cfg, signerOpts) 254 | } 255 | 256 | return newV4Signer(s.Config.Credentials, signerOpts), nil 257 | default: 258 | if cfg.AssumeRoleARN != "" { 259 | s, err := session.NewSession(&aws.Config{ 260 | Region: aws.String(cfg.Region), 261 | }) 262 | if err != nil { 263 | return nil, err 264 | } 265 | return getAssumeRoleSigner(s, cfg, signerOpts) 266 | } 267 | return nil, fmt.Errorf("invalid SigV4 auth type %q", authType) 268 | } 269 | 270 | if cfg.AssumeRoleARN != "" { 271 | s, err := session.NewSession(&aws.Config{ 272 | Region: aws.String(cfg.Region), 273 | Credentials: c}, 274 | ) 275 | if err != nil { 276 | return nil, err 277 | } 278 | return getAssumeRoleSigner(s, cfg, signerOpts) 279 | } 280 | 281 | return newV4Signer(c, signerOpts), nil 282 | } 283 | 284 | func getAssumeRoleSigner(s *session.Session, cfg *Config, signerOpts func(s *v4.Signer)) (*v4.Signer, error) { 285 | if cfg.ExternalID != "" { 286 | return newV4Signer(newStsCreds(s, cfg.AssumeRoleARN, func(p *stscreds.AssumeRoleProvider) { 287 | p.ExternalID = aws.String(cfg.ExternalID) 288 | }), signerOpts), nil 289 | } 290 | return newV4Signer(newStsCreds(s, cfg.AssumeRoleARN), signerOpts), nil 291 | } 292 | 293 | func copyHeaderWithoutOverwrite(dst, src http.Header) { 294 | for k, vv := range src { 295 | if _, ok := dst[k]; !ok { 296 | for _, v := range vv { 297 | dst.Add(k, v) 298 | } 299 | } 300 | } 301 | } 302 | 303 | func validateConfig(cfg *Config) error { 304 | _, err := awsds.ToAuthType(cfg.AuthType) 305 | if err != nil { 306 | return err 307 | } 308 | 309 | return nil 310 | } 311 | 312 | func (m *middleware) logRequest(req *http.Request, args ...interface{}) { 313 | if !m.verboseMode { 314 | return 315 | } 316 | dump, err := httputil.DumpRequest(req, true) 317 | if err != nil { 318 | backend.Logger.Error("Unable to dump request", "err", err) 319 | } 320 | backend.Logger.Debug("Request dump", append([]interface{}{"dump", string(dump)}, args...)...) 321 | } 322 | 323 | type awsLoggerAdapter struct { 324 | logger log.Logger 325 | } 326 | 327 | func (a awsLoggerAdapter) Log(args ...interface{}) { 328 | a.logger.Debug("[AWS SigV4 log]", "args", args) 329 | } 330 | -------------------------------------------------------------------------------- /pkg/sigv4/sigv4_middleware.go: -------------------------------------------------------------------------------- 1 | package sigv4 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" 9 | ) 10 | 11 | // SigV4MiddlewareName the middleware name used by SigV4Middleware. 12 | const SigV4MiddlewareName = "sigv4" 13 | 14 | var newSigV4Func = New 15 | 16 | // SigV4MiddlewareWithAuthSettings applies AWS Signature Version 4 request signing for the outgoing request. 17 | // AuthSettings can be gotten from the datasource instance's context with awsds.ReadAuthSettingsFromContext 18 | func SigV4MiddlewareWithAuthSettings(verboseLogging bool, authSettings awsds.AuthSettings) httpclient.Middleware { 19 | return httpclient.NamedMiddlewareFunc(SigV4MiddlewareName, func(opts httpclient.Options, next http.RoundTripper) http.RoundTripper { 20 | if opts.SigV4 == nil { 21 | return next 22 | } 23 | 24 | conf := &Config{ 25 | Service: opts.SigV4.Service, 26 | AccessKey: opts.SigV4.AccessKey, 27 | SecretKey: opts.SigV4.SecretKey, 28 | Region: opts.SigV4.Region, 29 | AssumeRoleARN: opts.SigV4.AssumeRoleARN, 30 | AuthType: opts.SigV4.AuthType, 31 | ExternalID: opts.SigV4.ExternalID, 32 | Profile: opts.SigV4.Profile, 33 | } 34 | 35 | rt, err := newSigV4Func(conf, authSettings, next, Opts{VerboseMode: verboseLogging}) 36 | if err != nil { 37 | return invalidSigV4Config(err) 38 | } 39 | 40 | return rt 41 | }) 42 | } 43 | 44 | // SigV4Middleware applies AWS Signature Version 4 request signing for the outgoing request. 45 | // Deprecated: Use SigV4MiddlewareWithAuthSettings instead 46 | func SigV4Middleware(verboseLogging bool) httpclient.Middleware { 47 | return SigV4MiddlewareWithAuthSettings(verboseLogging, *awsds.ReadAuthSettingsFromEnvironmentVariables()) 48 | } 49 | 50 | func invalidSigV4Config(err error) http.RoundTripper { 51 | return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { 52 | return nil, fmt.Errorf("invalid SigV4 configuration: %w", err) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/sigv4/sigv4_middleware_test.go: -------------------------------------------------------------------------------- 1 | package sigv4 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type testContext struct { 16 | callChain []string 17 | } 18 | 19 | func (c *testContext) createRoundTripper(name string) http.RoundTripper { 20 | return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { 21 | c.callChain = append(c.callChain, name) 22 | return &http.Response{ 23 | StatusCode: http.StatusOK, 24 | Request: req, 25 | Body: io.NopCloser(bytes.NewBufferString("")), 26 | }, nil 27 | }) 28 | } 29 | 30 | func TestSigV4Middleware(t *testing.T) { 31 | t.Run("Without sigv4 options set should return next http.RoundTripper", func(t *testing.T) { 32 | origSigV4Func := newSigV4Func 33 | newSigV4Called := false 34 | middlewareCalled := false 35 | newSigV4Func = func(config *Config, authSettings awsds.AuthSettings, next http.RoundTripper, opts ...Opts) (http.RoundTripper, error) { 36 | newSigV4Called = true 37 | return httpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { 38 | middlewareCalled = true 39 | return next.RoundTrip(r) 40 | }), nil 41 | } 42 | t.Cleanup(func() { 43 | newSigV4Func = origSigV4Func 44 | }) 45 | 46 | ctx := &testContext{} 47 | finalRoundTripper := ctx.createRoundTripper("finalrt") 48 | mw := SigV4Middleware(false) 49 | rt := mw.CreateMiddleware(httpclient.Options{}, finalRoundTripper) 50 | require.NotNil(t, rt) 51 | middlewareName, ok := mw.(httpclient.MiddlewareName) 52 | require.True(t, ok) 53 | require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) 54 | 55 | req, err := http.NewRequest(http.MethodGet, "http://", nil) 56 | require.NoError(t, err) 57 | res, err := rt.RoundTrip(req) 58 | require.NoError(t, err) 59 | require.NotNil(t, res) 60 | if res.Body != nil { 61 | require.NoError(t, res.Body.Close()) 62 | } 63 | require.Len(t, ctx.callChain, 1) 64 | require.ElementsMatch(t, []string{"finalrt"}, ctx.callChain) 65 | require.False(t, newSigV4Called) 66 | require.False(t, middlewareCalled) 67 | }) 68 | 69 | t.Run("With sigv4 options set should call sigv4 http.RoundTripper", func(t *testing.T) { 70 | origSigV4Func := newSigV4Func 71 | newSigV4Called := false 72 | middlewareCalled := false 73 | newSigV4Func = func(config *Config, authSettings awsds.AuthSettings, next http.RoundTripper, opts ...Opts) (http.RoundTripper, error) { 74 | newSigV4Called = true 75 | return httpclient.RoundTripperFunc(func(r *http.Request) (*http.Response, error) { 76 | middlewareCalled = true 77 | return next.RoundTrip(r) 78 | }), nil 79 | } 80 | t.Cleanup(func() { 81 | newSigV4Func = origSigV4Func 82 | }) 83 | 84 | ctx := &testContext{} 85 | finalRoundTripper := ctx.createRoundTripper("final") 86 | mw := SigV4Middleware(false) 87 | rt := mw.CreateMiddleware(httpclient.Options{SigV4: &httpclient.SigV4Config{}}, finalRoundTripper) 88 | require.NotNil(t, rt) 89 | middlewareName, ok := mw.(httpclient.MiddlewareName) 90 | require.True(t, ok) 91 | require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) 92 | 93 | req, err := http.NewRequest(http.MethodGet, "http://", nil) 94 | require.NoError(t, err) 95 | res, err := rt.RoundTrip(req) 96 | require.NoError(t, err) 97 | require.NotNil(t, res) 98 | if res.Body != nil { 99 | require.NoError(t, res.Body.Close()) 100 | } 101 | require.Len(t, ctx.callChain, 1) 102 | require.ElementsMatch(t, []string{"final"}, ctx.callChain) 103 | 104 | require.True(t, newSigV4Called) 105 | require.True(t, middlewareCalled) 106 | }) 107 | 108 | t.Run("With sigv4 error returned", func(t *testing.T) { 109 | origSigV4Func := newSigV4Func 110 | newSigV4Func = func(config *Config, authSettings awsds.AuthSettings, next http.RoundTripper, opts ...Opts) (http.RoundTripper, error) { 111 | return nil, fmt.Errorf("problem") 112 | } 113 | t.Cleanup(func() { 114 | newSigV4Func = origSigV4Func 115 | }) 116 | 117 | ctx := &testContext{} 118 | finalRoundTripper := ctx.createRoundTripper("final") 119 | mw := SigV4Middleware(false) 120 | rt := mw.CreateMiddleware(httpclient.Options{SigV4: &httpclient.SigV4Config{}}, finalRoundTripper) 121 | require.NotNil(t, rt) 122 | middlewareName, ok := mw.(httpclient.MiddlewareName) 123 | require.True(t, ok) 124 | require.Equal(t, SigV4MiddlewareName, middlewareName.MiddlewareName()) 125 | 126 | req, err := http.NewRequest(http.MethodGet, "http://", nil) 127 | require.NoError(t, err) 128 | // response is nil 129 | // nolint:bodyclose 130 | res, err := rt.RoundTrip(req) 131 | require.Error(t, err) 132 | require.Nil(t, res) 133 | require.Empty(t, ctx.callChain) 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /pkg/sigv4/sigv4_test.go: -------------------------------------------------------------------------------- 1 | package sigv4 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 12 | 13 | "github.com/aws/aws-sdk-go/aws/client" 14 | "github.com/aws/aws-sdk-go/aws/credentials" 15 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 16 | v4 "github.com/aws/aws-sdk-go/aws/signer/v4" 17 | 18 | "github.com/stretchr/testify/require" 19 | ) 20 | 21 | func TestNew(t *testing.T) { 22 | t.Run("Can't create new middleware without valid auth type", func(t *testing.T) { 23 | rt, err := New(&Config{}, awsds.AuthSettings{}, nil) 24 | require.Error(t, err) 25 | require.Nil(t, rt) 26 | 27 | }) 28 | t.Run("Can create new middleware with any valid auth type", func(t *testing.T) { 29 | for _, authType := range []string{"credentials", "sharedCreds", "keys", "default", "ec2_iam_role", "arn"} { 30 | rt, err := New(&Config{AuthType: authType}, awsds.AuthSettings{}, nil) 31 | 32 | require.NoError(t, err) 33 | require.NotNil(t, rt) 34 | } 35 | }) 36 | 37 | t.Run("Can sign a request", func(t *testing.T) { 38 | cfg := &Config{AuthType: "default"} 39 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}) 40 | require.NoError(t, err) 41 | require.NotNil(t, rt) 42 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 43 | require.NoError(t, err) 44 | 45 | // mock signer 46 | sha, err := cfg.asSha256() 47 | require.NoError(t, err) 48 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{}))) 49 | 50 | res, err := rt.RoundTrip(r) 51 | require.NoError(t, err) 52 | require.NotNil(t, res) 53 | 54 | require.Equal(t, r.Host, res.Request.Host) 55 | require.Equal(t, r.URL, res.Request.URL) 56 | require.Equal(t, r.RequestURI, res.Request.RequestURI) 57 | require.Equal(t, r.Method, res.Request.Method) 58 | require.NotNil(t, res.Request.Body) 59 | require.Equal(t, r.ContentLength, res.Request.ContentLength) 60 | 61 | authHeader := res.Request.Header.Get("Authorization") 62 | require.NotEmpty(t, authHeader) 63 | require.True(t, strings.Contains(authHeader, "SignedHeaders=host;x-amz-date,")) 64 | require.NotEmpty(t, res.Request.Header.Get("X-Amz-Date")) 65 | }) 66 | 67 | t.Run("Can sign a request with extra headers which are not signed", func(t *testing.T) { 68 | cfg := &Config{AuthType: "default"} 69 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}) 70 | require.NoError(t, err) 71 | require.NotNil(t, rt) 72 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 73 | require.NoError(t, err) 74 | 75 | r.Header.Add("Foo", "Bar") 76 | 77 | // mock signer 78 | sha, err := cfg.asSha256() 79 | require.NoError(t, err) 80 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{}))) 81 | 82 | res, err := rt.RoundTrip(r) 83 | require.NoError(t, err) 84 | require.NotNil(t, res) 85 | 86 | require.Equal(t, r.Host, res.Request.Host) 87 | require.Equal(t, r.URL, res.Request.URL) 88 | require.Equal(t, r.RequestURI, res.Request.RequestURI) 89 | require.Equal(t, r.Method, res.Request.Method) 90 | require.NotNil(t, res.Request.Body) 91 | require.Equal(t, r.ContentLength, res.Request.ContentLength) 92 | 93 | authHeader := res.Request.Header.Get("Authorization") 94 | require.NotEmpty(t, authHeader) 95 | require.True(t, strings.Contains(authHeader, "SignedHeaders=host;x-amz-date,")) 96 | require.NotEmpty(t, res.Request.Header.Get("X-Amz-Date")) 97 | require.Equal(t, "Bar", res.Request.Header.Get("Foo")) 98 | }) 99 | 100 | t.Run("Signed request overwrites existing Authorization header", func(t *testing.T) { 101 | cfg := &Config{AuthType: "default"} 102 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}) 103 | require.NoError(t, err) 104 | require.NotNil(t, rt) 105 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 106 | require.NoError(t, err) 107 | 108 | r.Header.Add("Authorization", "test") 109 | 110 | // mock signer 111 | sha, err := cfg.asSha256() 112 | require.NoError(t, err) 113 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{}))) 114 | 115 | res, err := rt.RoundTrip(r) 116 | require.NoError(t, err) 117 | require.NotNil(t, res) 118 | 119 | authHeader := res.Request.Header.Get("Authorization") 120 | require.NotEqual(t, "test", authHeader) 121 | require.True(t, strings.Contains(authHeader, "AWS4-HMAC-SHA256")) 122 | require.True(t, strings.Contains(authHeader, "SignedHeaders=")) 123 | require.True(t, strings.Contains(authHeader, "Signature=")) 124 | }) 125 | 126 | t.Run("Can't sign a request without valid credentials", func(t *testing.T) { 127 | cfg := &Config{AuthType: "ec2_iam_role"} 128 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}) 129 | require.NoError(t, err) 130 | require.NotNil(t, rt) 131 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 132 | require.NoError(t, err) 133 | 134 | // mock signer 135 | sha, err := cfg.asSha256() 136 | require.NoError(t, err) 137 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{noCredentials: true}))) 138 | 139 | res, err := rt.RoundTrip(r) 140 | require.Error(t, err) 141 | require.Nil(t, res) 142 | }) 143 | 144 | t.Run("Will log requests during signing if verboseMode is true", func(t *testing.T) { 145 | cfg := &Config{AuthType: "ec2_iam_role"} 146 | 147 | // Mock logger 148 | origLogger := backend.Logger 149 | t.Cleanup(func() { 150 | backend.Logger = origLogger 151 | }) 152 | 153 | fakeLogger := &fakeLogger{} 154 | backend.Logger = fakeLogger 155 | 156 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}, Opts{VerboseMode: true}) 157 | require.NoError(t, err) 158 | require.NotNil(t, rt) 159 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 160 | require.NoError(t, err) 161 | 162 | // mock signer 163 | sha, err := cfg.asSha256() 164 | require.NoError(t, err) 165 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{}))) 166 | 167 | res, err := rt.RoundTrip(r) 168 | require.NoError(t, err) 169 | require.NotNil(t, res) 170 | 171 | require.Equal(t, 2, len(fakeLogger.logs)) 172 | require.Equal(t, "Request dump", fakeLogger.logs[0]) 173 | require.Equal(t, "Request dump", fakeLogger.logs[1]) 174 | }) 175 | 176 | t.Run("Will not log requests during signing if verboseMode is false", func(t *testing.T) { 177 | cfg := &Config{AuthType: "ec2_iam_role"} 178 | 179 | // Mock logger 180 | origLogger := backend.Logger 181 | t.Cleanup(func() { 182 | backend.Logger = origLogger 183 | }) 184 | 185 | fakeLogger := &fakeLogger{} 186 | backend.Logger = fakeLogger 187 | 188 | rt, err := New(cfg, awsds.AuthSettings{}, &fakeTransport{}, Opts{VerboseMode: false}) 189 | require.NoError(t, err) 190 | require.NotNil(t, rt) 191 | r, err := http.NewRequest("GET", "http://grafana.sigv4.test", nil) 192 | require.NoError(t, err) 193 | 194 | // mock signer 195 | sha, err := cfg.asSha256() 196 | require.NoError(t, err) 197 | signerCache.Store(sha, v4.NewSigner(credentials.NewCredentials(&mockCredentialsProvider{}))) 198 | 199 | res, err := rt.RoundTrip(r) 200 | require.NoError(t, err) 201 | require.NotNil(t, res) 202 | 203 | require.Empty(t, fakeLogger.logs) 204 | }) 205 | } 206 | 207 | func TestConfig(t *testing.T) { 208 | t.Run("SHA generation is consistent", func(t *testing.T) { 209 | cfg := &Config{ 210 | AuthType: "A", 211 | Profile: "B", 212 | Service: "C", 213 | AccessKey: "D", 214 | SecretKey: "E", 215 | SessionToken: "F", 216 | AssumeRoleARN: "G", 217 | ExternalID: "H", 218 | Region: "I", 219 | } 220 | 221 | sha1, err := cfg.asSha256() 222 | require.NoError(t, err) 223 | 224 | sha2, err := cfg.asSha256() 225 | require.NoError(t, err) 226 | 227 | require.Equal(t, sha1, sha2) 228 | }) 229 | 230 | t.Run("Config field order does not affect SHA", func(t *testing.T) { 231 | cfg1 := &Config{ 232 | AuthType: "A", 233 | Profile: "B", 234 | Service: "C", 235 | AccessKey: "D", 236 | SecretKey: "E", 237 | SessionToken: "F", 238 | AssumeRoleARN: "G", 239 | ExternalID: "H", 240 | Region: "I", 241 | } 242 | 243 | cfg2 := &Config{ 244 | Region: "I", 245 | ExternalID: "H", 246 | AssumeRoleARN: "G", 247 | SessionToken: "F", 248 | SecretKey: "E", 249 | AccessKey: "D", 250 | Service: "C", 251 | Profile: "B", 252 | AuthType: "A", 253 | } 254 | 255 | sha1, err := cfg1.asSha256() 256 | require.NoError(t, err) 257 | 258 | sha2, err := cfg2.asSha256() 259 | require.NoError(t, err) 260 | 261 | require.Equal(t, sha1, sha2) 262 | }) 263 | 264 | t.Run("Config SHA changes depending on contents", func(t *testing.T) { 265 | cfg1 := &Config{ 266 | AuthType: "A", 267 | Profile: "B", 268 | Service: "C", 269 | AccessKey: "D", 270 | SecretKey: "E", 271 | SessionToken: "F", 272 | AssumeRoleARN: "G", 273 | ExternalID: "H", 274 | Region: "I", 275 | } 276 | 277 | cfg2 := &Config{ 278 | AuthType: "AB", 279 | Profile: "B", 280 | Service: "C", 281 | AccessKey: "D", 282 | SecretKey: "E", 283 | SessionToken: "F", 284 | AssumeRoleARN: "G", 285 | ExternalID: "H", 286 | Region: "I", 287 | } 288 | 289 | sha1, err := cfg1.asSha256() 290 | require.NoError(t, err) 291 | 292 | sha2, err := cfg2.asSha256() 293 | require.NoError(t, err) 294 | 295 | require.NotEqual(t, sha1, sha2) 296 | 297 | cfg2.AuthType = "A" 298 | 299 | sha2, err = cfg2.asSha256() 300 | require.NoError(t, err) 301 | 302 | require.Equal(t, sha1, sha2) 303 | }) 304 | } 305 | 306 | func TestCreateSigner_UsesExternalID_WhenProvided(t *testing.T) { 307 | for _, tc := range []struct { 308 | authType string 309 | }{ 310 | {authType: "default"}, 311 | {authType: "credentials"}, 312 | {authType: "keys"}, 313 | {authType: "ec2_iam_role"}, 314 | {authType: "grafana_assume_role"}, 315 | } { 316 | t.Run(fmt.Sprintf("AuthType: %s", tc.authType), func(t *testing.T) { 317 | // Capture the external ID passed into the AssumeRoleProvider 318 | var capturedExternalID string 319 | var signerCalled bool 320 | 321 | // Mock stscreds.NewCredentials 322 | newStsCreds = func(c client.ConfigProvider, arn string, optFns ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials { 323 | provider := &stscreds.AssumeRoleProvider{} 324 | for _, opt := range optFns { 325 | opt(provider) 326 | } 327 | if provider.ExternalID != nil { 328 | capturedExternalID = *provider.ExternalID 329 | } 330 | return credentials.NewStaticCredentials("mock-access", "mock-secret", "mock-token") 331 | } 332 | 333 | // Mock v4.NewSigner 334 | newV4Signer = func(creds *credentials.Credentials, opts ...func(s *v4.Signer)) *v4.Signer { 335 | signerCalled = true 336 | return &v4.Signer{} 337 | } 338 | 339 | // Restore mocks 340 | defer func() { 341 | newStsCreds = stscreds.NewCredentials 342 | newV4Signer = v4.NewSigner 343 | }() 344 | 345 | cfg := &Config{ 346 | Region: "us-east-2", 347 | AuthType: tc.authType, 348 | AssumeRoleARN: "arn:aws:iam::123456789:role/test-role", 349 | ExternalID: "external-id-123", 350 | } 351 | 352 | signer, err := createSigner(cfg, awsds.AuthSettings{ 353 | AllowedAuthProviders: []string{ 354 | "default", 355 | "credentials", 356 | "keys", 357 | "ec2_iam_role", 358 | "grafana_assume_role", 359 | }, 360 | AssumeRoleEnabled: true, 361 | }, false) 362 | 363 | require.NoError(t, err) 364 | require.NotNil(t, signer) 365 | require.True(t, signerCalled) 366 | require.Equal(t, "external-id-123", capturedExternalID) 367 | }) 368 | } 369 | } 370 | 371 | type mockCredentialsProvider struct { 372 | credentials.Provider 373 | noCredentials bool 374 | } 375 | 376 | func (m *mockCredentialsProvider) Retrieve() (credentials.Value, error) { 377 | if m.noCredentials { 378 | return credentials.Value{}, fmt.Errorf("no valid credentials") 379 | } 380 | return credentials.Value{}, nil 381 | } 382 | 383 | type fakeTransport struct{} 384 | 385 | func (t *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { 386 | return &http.Response{ 387 | Header: make(http.Header), 388 | Request: req, 389 | StatusCode: http.StatusOK, 390 | }, nil 391 | } 392 | 393 | type fakeLogger struct { 394 | log.Logger 395 | 396 | logs []string 397 | } 398 | 399 | func (l *fakeLogger) Debug(msg string, _ ...interface{}) { 400 | l.logs = append(l.logs, msg) 401 | } 402 | -------------------------------------------------------------------------------- /pkg/sql/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 11 | "github.com/grafana/sqlds/v4" 12 | "github.com/jpillora/backoff" 13 | ) 14 | 15 | var ( 16 | ExecuteError = errors.New("error executing query") 17 | StatusError = errors.New("error getting query query status") 18 | StopError = errors.New("error stopping query") 19 | 20 | backoffMin = 200 * time.Millisecond 21 | backoffMax = 10 * time.Minute 22 | ) 23 | 24 | type ExecuteQueryInput struct { 25 | ID string 26 | Query string 27 | } 28 | 29 | type ExecuteQueryOutput struct { 30 | ID string 31 | } 32 | 33 | type ExecuteQueryStatus struct { 34 | ID string 35 | Finished bool 36 | State string 37 | } 38 | 39 | type SQL interface { 40 | Execute(aws.Context, *ExecuteQueryInput) (*ExecuteQueryOutput, error) 41 | Status(aws.Context, *ExecuteQueryOutput) (*ExecuteQueryStatus, error) 42 | Stop(*ExecuteQueryOutput) error 43 | } 44 | 45 | type Resources interface { 46 | Regions(aws.Context) ([]string, error) 47 | Databases(aws.Context, sqlds.Options) ([]string, error) 48 | CancelQuery(aws.Context, sqlds.Options, string) error 49 | } 50 | 51 | type AWSAPI interface { 52 | SQL 53 | Resources 54 | } 55 | 56 | // WaitOnQuery polls the datasource api until the query finishes, returning an error if it failed. 57 | func WaitOnQuery(ctx context.Context, api SQL, output *ExecuteQueryOutput) error { 58 | backoffInstance := backoff.Backoff{ 59 | Min: backoffMin, 60 | Max: backoffMax, 61 | Factor: 1.1, 62 | } 63 | for { 64 | status, err := api.Status(ctx, output) 65 | if err != nil { 66 | return err 67 | } 68 | if status.Finished { 69 | return nil 70 | } 71 | select { 72 | case <-ctx.Done(): 73 | err := ctx.Err() 74 | if errors.Is(err, context.Canceled) { 75 | err := api.Stop(output) 76 | if err != nil { 77 | return err 78 | } 79 | } 80 | log.DefaultLogger.Debug("request failed", "query ID", output.ID, "error", err) 81 | return err 82 | case <-time.After(backoffInstance.Duration()): 83 | continue 84 | } 85 | } 86 | } 87 | 88 | func WaitOnQueryID(ctx context.Context, queryID string, db awsds.AsyncDB) error { 89 | backoffInstance := backoff.Backoff{ 90 | Min: backoffMin, 91 | Max: backoffMax, 92 | Factor: 2, 93 | } 94 | for { 95 | status, err := db.QueryStatus(ctx, queryID) 96 | if err != nil { 97 | return err 98 | } 99 | if status.Finished() { 100 | return nil 101 | } 102 | select { 103 | case <-ctx.Done(): 104 | err := ctx.Err() 105 | if errors.Is(err, context.Canceled) { 106 | err := db.CancelQuery(context.Background(), queryID) 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | log.DefaultLogger.Debug("request failed", "query ID", queryID, "error", err) 112 | return err 113 | case <-time.After(backoffInstance.Duration()): 114 | continue 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /pkg/sql/api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | ) 11 | 12 | type fakeDS struct { 13 | statusCounter int 14 | status []*ExecuteQueryStatus 15 | statusErr error 16 | stopped bool 17 | } 18 | 19 | func (s *fakeDS) Execute(_ aws.Context, _ *ExecuteQueryInput) (*ExecuteQueryOutput, error) { 20 | return &ExecuteQueryOutput{}, nil 21 | } 22 | 23 | func (s *fakeDS) Stop(*ExecuteQueryOutput) error { 24 | s.stopped = true 25 | return nil 26 | } 27 | 28 | func (s *fakeDS) Status(aws.Context, *ExecuteQueryOutput) (*ExecuteQueryStatus, error) { 29 | i := s.statusCounter 30 | s.statusCounter++ 31 | return s.status[i], s.statusErr 32 | } 33 | 34 | func TestWaitOnQuery(t *testing.T) { 35 | // for tests we override backoff instance to always take 1 millisecond so the tests run quickly 36 | backoffMin = 1 * time.Millisecond 37 | backoffMax = 1 * time.Millisecond 38 | 39 | tests := []struct { 40 | description string 41 | ds *fakeDS 42 | expectedErr error 43 | }{ 44 | { 45 | "returns with no error", 46 | &fakeDS{ 47 | statusCounter: 0, 48 | status: []*ExecuteQueryStatus{{Finished: true}}, 49 | statusErr: nil, 50 | }, 51 | nil, 52 | }, 53 | { 54 | "returns with no error after several calls", 55 | &fakeDS{ 56 | statusCounter: 0, 57 | status: []*ExecuteQueryStatus{{Finished: false}, {Finished: true}}, 58 | statusErr: nil, 59 | }, 60 | nil, 61 | }, 62 | { 63 | "returns an error", 64 | &fakeDS{ 65 | statusCounter: 0, 66 | status: []*ExecuteQueryStatus{{}}, 67 | statusErr: StatusError, 68 | }, 69 | nil, 70 | }, 71 | } 72 | 73 | for _, tc := range tests { 74 | t.Run(tc.description, func(t *testing.T) { 75 | err := WaitOnQuery(context.Background(), tc.ds, &ExecuteQueryOutput{}) 76 | if tc.ds.statusCounter != len(tc.ds.status) { 77 | t.Errorf("status not called the right amount of times. Want %d got %d", len(tc.ds.status), tc.ds.statusCounter) 78 | } 79 | if (err != nil || tc.ds.statusErr != nil) && !errors.Is(err, tc.ds.statusErr) { 80 | t.Errorf("unexpected error %v", err) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestConnection_waitOnQueryCancelled(t *testing.T) { 87 | // add a big timeout to have time to cancel 88 | backoffMin = 10000 * time.Millisecond 89 | backoffMax = 10000 * time.Millisecond 90 | 91 | ds := &fakeDS{ 92 | statusCounter: 0, 93 | status: []*ExecuteQueryStatus{{Finished: false}}, 94 | } 95 | 96 | ctx, cancel := context.WithCancel(context.Background()) 97 | done := make(chan bool) 98 | 99 | // start the execution in parallel 100 | go func() { 101 | err := WaitOnQuery(ctx, ds, &ExecuteQueryOutput{}) 102 | if err == nil || !errors.Is(err, context.Canceled) { 103 | t.Errorf("unexpected error %v", err) 104 | } 105 | done <- true 106 | }() 107 | cancel() 108 | <-done 109 | 110 | if !ds.stopped { 111 | t.Errorf("failed to cancel the request") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/sql/datasource/datasource.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "sync" 8 | 9 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 10 | "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 11 | "github.com/grafana/grafana-aws-sdk/pkg/sql/driver" 12 | asyncDriver "github.com/grafana/grafana-aws-sdk/pkg/sql/driver/async" 13 | "github.com/grafana/grafana-aws-sdk/pkg/sql/models" 14 | "github.com/grafana/grafana-plugin-sdk-go/backend" 15 | "github.com/grafana/sqlds/v4" 16 | ) 17 | 18 | // AWSClient provides creation and caching of sessions, database connections, and API clients 19 | type AWSClient interface { 20 | Init(config backend.DataSourceInstanceSettings) 21 | GetDB(ctx context.Context, id int64, options sqlds.Options) (*sql.DB, error) 22 | GetAsyncDB(ctx context.Context, id int64, options sqlds.Options) (awsds.AsyncDB, error) 23 | GetAPI(ctx context.Context, id int64, options sqlds.Options) (api.AWSAPI, error) 24 | } 25 | 26 | type Loader interface { 27 | LoadSettings(context.Context) models.Settings 28 | LoadAPI(context.Context, *awsds.SessionCache, models.Settings) (api.AWSAPI, error) 29 | LoadDriver(context.Context, api.AWSAPI) (driver.Driver, error) 30 | LoadAsyncDriver(context.Context, api.AWSAPI) (asyncDriver.Driver, error) 31 | } 32 | 33 | // awsClient provides creation and caching of several types of instances. 34 | // Each Map will depend on the datasource ID (and connection options): 35 | // - sessionCache: AWS cache. This is not a Map since it does not depend on the datasource. 36 | // - config: Base configuration. It will be used as base to populate datasource settings. 37 | // It does not depend on connection options (only one per datasource) 38 | // - api: API instance with the common methods to contact the data source API. 39 | type awsClient struct { 40 | sessionCache *awsds.SessionCache 41 | config sync.Map 42 | api sync.Map 43 | 44 | loader Loader 45 | } 46 | 47 | func New(loader Loader) AWSClient { 48 | ds := &awsClient{sessionCache: awsds.NewSessionCache(), loader: loader} 49 | return ds 50 | } 51 | 52 | func (ds *awsClient) storeConfig(config backend.DataSourceInstanceSettings) { 53 | ds.config.Store(config.ID, config) 54 | } 55 | 56 | func (ds *awsClient) createDB(dr driver.Driver) (*sql.DB, error) { 57 | db, err := dr.OpenDB() 58 | if err != nil { 59 | return nil, fmt.Errorf("%w: failed to connect to database (check hostname and port?)", err) 60 | } 61 | 62 | return db, nil 63 | } 64 | 65 | func (ds *awsClient) createAsyncDB(dr asyncDriver.Driver) (awsds.AsyncDB, error) { 66 | db, err := dr.GetAsyncDB() 67 | if err != nil { 68 | return nil, fmt.Errorf("%w: failed to connect to database (check hostname and port)", err) 69 | } 70 | 71 | return db, nil 72 | } 73 | 74 | func (ds *awsClient) storeAPI(id int64, args sqlds.Options, dsAPI api.AWSAPI) { 75 | key := connectionKey(id, args) 76 | ds.api.Store(key, dsAPI) 77 | } 78 | 79 | func (ds *awsClient) loadAPI(id int64, args sqlds.Options) (api.AWSAPI, bool) { 80 | key := connectionKey(id, args) 81 | dsAPI, exists := ds.api.Load(key) 82 | if exists { 83 | return dsAPI.(api.AWSAPI), true 84 | } 85 | return nil, false 86 | } 87 | 88 | func (ds *awsClient) createAPI(ctx context.Context, id int64, args sqlds.Options, settings models.Settings) (api.AWSAPI, error) { 89 | dsAPI, err := ds.loader.LoadAPI(ctx, ds.sessionCache, settings) 90 | if err != nil { 91 | return nil, fmt.Errorf("%w: Failed to create client", err) 92 | } 93 | ds.storeAPI(id, args, dsAPI) 94 | return dsAPI, err 95 | } 96 | 97 | func (ds *awsClient) createDriver(ctx context.Context, dsAPI api.AWSAPI) (driver.Driver, error) { 98 | dr, err := ds.loader.LoadDriver(ctx, dsAPI) 99 | if err != nil { 100 | return nil, fmt.Errorf("%w: Failed to create client", err) 101 | } 102 | 103 | return dr, nil 104 | } 105 | 106 | func (ds *awsClient) createAsyncDriver(ctx context.Context, dsAPI api.AWSAPI) (asyncDriver.Driver, error) { 107 | dr, err := ds.loader.LoadAsyncDriver(ctx, dsAPI) 108 | if err != nil { 109 | return nil, fmt.Errorf("%w: Failed to create client", err) 110 | } 111 | 112 | return dr, nil 113 | } 114 | 115 | func (ds *awsClient) parseSettings(id int64, args sqlds.Options, settings models.Settings) error { 116 | config, ok := ds.config.Load(id) 117 | if !ok { 118 | return fmt.Errorf("unable to find stored configuration for datasource %d. Initialize it first", id) 119 | } 120 | err := settings.Load(config.(backend.DataSourceInstanceSettings)) 121 | if err != nil { 122 | return fmt.Errorf("error reading settings: %s", err.Error()) 123 | } 124 | settings.Apply(args) 125 | return nil 126 | } 127 | 128 | // Init stores the data source configuration. It's needed for the GetDB and GetAPI functions 129 | func (ds *awsClient) Init(config backend.DataSourceInstanceSettings) { 130 | ds.storeConfig(config) 131 | } 132 | 133 | // GetDB returns a *sql.DB. It will use the loader functions to initialize the required 134 | // settings, API and driver and finally create a DB. 135 | func (ds *awsClient) GetDB( 136 | ctx context.Context, 137 | id int64, 138 | options sqlds.Options, 139 | ) (*sql.DB, error) { 140 | settings := ds.loader.LoadSettings(ctx) 141 | err := ds.parseSettings(id, options, settings) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | dsAPI, err := ds.createAPI(ctx, id, options, settings) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | dr, err := ds.createDriver(ctx, dsAPI) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return ds.createDB(dr) 157 | } 158 | 159 | // GetAsyncDB returns a sqlds.AsyncDB. It will use the loader functions to initialize the required 160 | // settings, API and driver and finally create a DB. 161 | func (ds *awsClient) GetAsyncDB( 162 | ctx context.Context, 163 | id int64, 164 | options sqlds.Options, 165 | ) (awsds.AsyncDB, error) { 166 | settings := ds.loader.LoadSettings(ctx) 167 | err := ds.parseSettings(id, options, settings) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | dsAPI, err := ds.createAPI(ctx, id, options, settings) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | dr, err := ds.createAsyncDriver(ctx, dsAPI) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | return ds.createAsyncDB(dr) 183 | } 184 | 185 | // GetAPI returns an API interface. When called multiple times with the same id and options, it 186 | // will return a cached version of the API. The first time, it will use the loader 187 | // functions to initialize the required settings and API. 188 | func (ds *awsClient) GetAPI( 189 | ctx context.Context, 190 | id int64, 191 | options sqlds.Options, 192 | ) (api.AWSAPI, error) { 193 | cachedAPI, exists := ds.loadAPI(id, options) 194 | if exists { 195 | return cachedAPI, nil 196 | } 197 | 198 | // create new api 199 | settings := ds.loader.LoadSettings(ctx) 200 | err := ds.parseSettings(id, options, settings) 201 | if err != nil { 202 | return nil, err 203 | } 204 | return ds.createAPI(ctx, id, options, settings) 205 | } 206 | -------------------------------------------------------------------------------- /pkg/sql/datasource/datasource_test.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "database/sql/driver" 7 | "testing" 8 | 9 | asyncDriver "github.com/grafana/grafana-aws-sdk/pkg/sql/driver/async" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 13 | sqlApi "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 14 | sqlDriver "github.com/grafana/grafana-aws-sdk/pkg/sql/driver" 15 | "github.com/grafana/grafana-aws-sdk/pkg/sql/models" 16 | "github.com/grafana/grafana-plugin-sdk-go/backend" 17 | "github.com/grafana/sqlds/v4" 18 | ) 19 | 20 | type fakeLoader struct { 21 | driver sqlDriver.Driver 22 | } 23 | 24 | func (m fakeLoader) LoadSettings(_ context.Context) models.Settings { 25 | return &fakeSettings{} 26 | } 27 | 28 | func (m fakeLoader) LoadAPI(_ context.Context, _ *awsds.SessionCache, _ models.Settings) (sqlApi.AWSAPI, error) { 29 | return fakeAPI{}, nil 30 | } 31 | 32 | func (m fakeLoader) LoadDriver(_ context.Context, _ sqlApi.AWSAPI) (sqlDriver.Driver, error) { 33 | return m.driver, nil 34 | } 35 | 36 | func (m fakeLoader) LoadAsyncDriver(_ context.Context, _ sqlApi.AWSAPI) (asyncDriver.Driver, error) { 37 | return nil, nil 38 | } 39 | func newFakeLoader(db *sql.DB) Loader { 40 | return fakeLoader{driver: &fakeDriver{db: db}} 41 | 42 | } 43 | 44 | func TestNew(t *testing.T) { 45 | ds := New(newFakeLoader(nil)) 46 | impl, ok := ds.(*awsClient) 47 | if !ok { 48 | t.Errorf("unexpected underlying type: %t", ds) 49 | } 50 | 51 | if impl.sessionCache == nil { 52 | t.Errorf("missing initialization") 53 | } 54 | } 55 | 56 | func TestInit(t *testing.T) { 57 | config := backend.DataSourceInstanceSettings{ 58 | ID: 100, 59 | } 60 | ds := &awsClient{loader: newFakeLoader(nil)} 61 | ds.Init(config) 62 | if _, ok := ds.config.Load(config.ID); !ok { 63 | t.Errorf("missing config") 64 | } 65 | } 66 | 67 | type fakeDriver struct { 68 | db *sql.DB 69 | closed bool 70 | } 71 | 72 | func (f *fakeDriver) Open(_ string) (driver.Conn, error) { 73 | return nil, nil 74 | } 75 | 76 | func (f *fakeDriver) Closed() bool { 77 | return f.closed 78 | } 79 | 80 | func (f *fakeDriver) OpenDB() (*sql.DB, error) { 81 | return f.db, nil 82 | } 83 | 84 | type fakeAPI struct { 85 | sqlApi.AWSAPI 86 | } 87 | 88 | func TestLoadAPI(t *testing.T) { 89 | api := &fakeAPI{} 90 | tests := []struct { 91 | description string 92 | id int64 93 | args sqlds.Options 94 | api *fakeAPI 95 | res *fakeAPI 96 | }{ 97 | { 98 | description: "it should return a stored api without args", 99 | id: 1, 100 | args: sqlds.Options{}, 101 | api: api, 102 | res: api, 103 | }, 104 | { 105 | description: "it should return a stored api with args", 106 | id: 1, 107 | args: sqlds.Options{"foo": "bar"}, 108 | api: api, 109 | res: api, 110 | }, 111 | { 112 | description: "it should return an empty response", 113 | id: 1, 114 | args: sqlds.Options{"foo": "bar"}, 115 | api: nil, 116 | res: nil, 117 | }, 118 | } 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.description, func(t *testing.T) { 122 | ds := &awsClient{loader: newFakeLoader(nil)} 123 | key := connectionKey(tt.id, tt.args) 124 | if tt.api != nil { 125 | ds.api.Store(key, tt.api) 126 | } 127 | res, exists := ds.loadAPI(tt.id, tt.args) 128 | if res != tt.res && (res != nil || tt.res != nil) { 129 | t.Errorf("unexpected result %v", res) 130 | } 131 | if tt.res != nil && !exists { 132 | t.Errorf("should return true") 133 | } 134 | }) 135 | } 136 | } 137 | 138 | type fakeSettings struct { 139 | settings backend.DataSourceInstanceSettings 140 | modifier sqlds.Options 141 | } 142 | 143 | func (f *fakeSettings) Load(c backend.DataSourceInstanceSettings) error { 144 | f.settings = c 145 | return nil 146 | } 147 | 148 | func (f *fakeSettings) Apply(args sqlds.Options) { 149 | f.modifier = args 150 | } 151 | 152 | func TestParseSettings(t *testing.T) { 153 | id := int64(1) 154 | args := sqlds.Options{"foo": "bar"} 155 | ds := &awsClient{loader: newFakeLoader(nil)} 156 | ds.config.Store(id, backend.DataSourceInstanceSettings{ID: id}) 157 | 158 | settings := &fakeSettings{} 159 | err := ds.parseSettings(id, args, settings) 160 | if err != nil { 161 | t.Errorf("unexpected error %v", err) 162 | } 163 | if settings.settings.ID != id { 164 | t.Errorf("failed to load config") 165 | } 166 | if settings.modifier["foo"] != "bar" { 167 | t.Errorf("failed to apply modifier") 168 | } 169 | } 170 | 171 | func TestCreateAPI(t *testing.T) { 172 | id := int64(1) 173 | args := sqlds.Options{"foo": "bar"} 174 | ds := &awsClient{loader: newFakeLoader(nil)} 175 | key := connectionKey(id, args) 176 | settings := &fakeSettings{} 177 | ctx := context.Background() 178 | 179 | api, err := ds.createAPI(ctx, id, args, settings) 180 | if err != nil { 181 | t.Errorf("unexpected error %v", err) 182 | } 183 | if !cmp.Equal(api, fakeAPI{}) { 184 | t.Errorf("unexpected result api %v", cmp.Diff(api, fakeAPI{})) 185 | } 186 | cachedAPI, ok := ds.api.Load(key) 187 | if !ok || !cmp.Equal(cachedAPI, fakeAPI{}) { 188 | t.Errorf("unexpected cached api %v", cmp.Diff(cachedAPI, fakeAPI{})) 189 | } 190 | } 191 | 192 | func TestCreateDriver(t *testing.T) { 193 | ctx := context.Background() 194 | loader := newFakeLoader(nil) 195 | ds := &awsClient{loader: loader} 196 | api, err := ds.createAPI(ctx, 0, sqlds.Options{}, loader.LoadSettings(ctx)) 197 | if err != nil { 198 | t.Errorf("unexpected error %v", err) 199 | } 200 | 201 | dr, err := ds.createDriver(context.Background(), api) 202 | if err != nil { 203 | t.Errorf("unexpected error %v", err) 204 | } 205 | if dr == nil { 206 | t.Errorf("unexpected result driver %v", dr) 207 | } 208 | } 209 | 210 | func TestCreateDB(t *testing.T) { 211 | db := &sql.DB{} 212 | dr := &fakeDriver{db: db} 213 | ds := &awsClient{loader: newFakeLoader(db)} 214 | 215 | res, err := ds.createDB(dr) 216 | if err != nil { 217 | t.Errorf("unexpected error %v", err) 218 | } 219 | if res != db { 220 | t.Errorf("unexpected result db %v", res) 221 | } 222 | } 223 | 224 | func TestGetDB(t *testing.T) { 225 | id := int64(1) 226 | args := sqlds.Options{"foo": "bar"} 227 | ds := &awsClient{loader: newFakeLoader(&sql.DB{})} 228 | config := backend.DataSourceInstanceSettings{ID: id} 229 | ds.Init(config) 230 | 231 | res, err := ds.GetDB(context.Background(), config.ID, args) 232 | if err != nil { 233 | t.Errorf("unexpected error %v", err) 234 | } 235 | if res == nil { 236 | t.Errorf("unexpected result db %v", res) 237 | } 238 | } 239 | 240 | func TestGetAPI(t *testing.T) { 241 | id := int64(1) 242 | args := sqlds.Options{"foo": "bar"} 243 | ds := &awsClient{loader: fakeLoader{}} 244 | config := backend.DataSourceInstanceSettings{ID: id} 245 | ds.Init(config) 246 | key := connectionKey(id, args) 247 | 248 | api, err := ds.GetAPI(context.Background(), id, args) 249 | if err != nil { 250 | t.Errorf("unexpected error %v", err) 251 | } 252 | if !cmp.Equal(api, fakeAPI{}) { 253 | t.Errorf("unexpected result api %v", cmp.Diff(api, fakeAPI{})) 254 | } 255 | cachedAPI, ok := ds.api.Load(key) 256 | if !ok || !cmp.Equal(cachedAPI, fakeAPI{}) { 257 | t.Errorf("unexpected cached api %v", cmp.Diff(cachedAPI, fakeAPI{})) 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /pkg/sql/datasource/utils.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | "github.com/grafana/sqlds/v4" 9 | ) 10 | 11 | func connectionKey(id int64, args sqlds.Options) string { 12 | return fmt.Sprintf("%d-%v", id, args) 13 | } 14 | 15 | func GetDatasourceID(ctx context.Context) int64 { 16 | plugin := backend.PluginConfigFromContext(ctx) 17 | if plugin.DataSourceInstanceSettings != nil { 18 | return plugin.DataSourceInstanceSettings.ID 19 | } 20 | return 0 21 | } 22 | 23 | func GetDatasourceLastUpdatedTime(ctx context.Context) string { 24 | plugin := backend.PluginConfigFromContext(ctx) 25 | if plugin.DataSourceInstanceSettings != nil { 26 | return plugin.DataSourceInstanceSettings.Updated.String() 27 | } 28 | return "" 29 | } 30 | -------------------------------------------------------------------------------- /pkg/sql/datasource/utils_test.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestGetDatasourceID(t *testing.T) { 9 | // It's not possible to test that GetDatasourceID returns an actual 10 | // ID because the ctx key is not exported. This just tests the fallback 11 | // path. 12 | if id := GetDatasourceID(context.Background()); id != 0 { 13 | t.Errorf("unexpected id: %d", id) 14 | } 15 | } 16 | 17 | func TestGetDatasourceLastUpdatedTime(t *testing.T) { 18 | // It's not possible to test that GetDatasourceLastUpdatedTime returns an actual 19 | // time because the ctx key is not exported. This just tests the fallback 20 | // path. 21 | if time := GetDatasourceLastUpdatedTime(context.Background()); time != "" { 22 | t.Errorf("unexpected time: %s", time) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/sql/driver/async/connection.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 9 | "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 10 | ) 11 | 12 | // Implements "*sql.DB" 13 | type Conn struct { 14 | db awsds.AsyncDB 15 | } 16 | 17 | func NewConnection(db awsds.AsyncDB) *Conn { 18 | return &Conn{db: db} 19 | } 20 | 21 | func (c *Conn) CheckNamedValue(v *driver.NamedValue) error { 22 | if v.Name != "queryID" { 23 | return fmt.Errorf("only queryID parameters are supported") 24 | } 25 | return nil 26 | } 27 | 28 | func (c *Conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { 29 | // Asynchronous flow 30 | queryID := "" 31 | for _, arg := range args { 32 | if arg.Name == "queryID" { 33 | queryID = arg.Value.(string) 34 | } 35 | } 36 | if queryID != "" { 37 | return c.db.GetRows(ctx, queryID) 38 | } 39 | // Synchronous flow 40 | queryID, err := c.db.StartQuery(ctx, query, args) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if err := api.WaitOnQueryID(ctx, queryID, c.db); err != nil { 46 | return nil, err 47 | } 48 | 49 | return c.db.GetRows(ctx, queryID) 50 | } 51 | 52 | func (c *Conn) Ping() error { 53 | return c.db.Ping(context.Background()) 54 | } 55 | 56 | func (c *Conn) PingContext(ctx context.Context) error { 57 | return c.db.Ping(ctx) 58 | } 59 | 60 | func (c *Conn) Begin() (driver.Tx, error) { 61 | // Ignore that the wrapped call is deprecated 62 | // nolint:staticcheck 63 | return c.db.Begin() 64 | } 65 | 66 | func (c *Conn) Prepare(query string) (driver.Stmt, error) { 67 | return c.db.Prepare(query) 68 | } 69 | 70 | func (c *Conn) Close() error { 71 | return c.db.Close() 72 | } 73 | -------------------------------------------------------------------------------- /pkg/sql/driver/async/driver.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "github.com/grafana/grafana-aws-sdk/pkg/awsds" 5 | "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 6 | sqlDriver "github.com/grafana/grafana-aws-sdk/pkg/sql/driver" 7 | ) 8 | 9 | type Driver interface { 10 | sqlDriver.Driver 11 | GetAsyncDB() (awsds.AsyncDB, error) 12 | } 13 | 14 | type Loader func(api.AWSAPI) (Driver, error) 15 | -------------------------------------------------------------------------------- /pkg/sql/driver/driver.go: -------------------------------------------------------------------------------- 1 | package driver 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | 7 | "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 8 | ) 9 | 10 | type Driver interface { 11 | Open(_ string) (driver.Conn, error) 12 | Closed() bool 13 | OpenDB() (*sql.DB, error) 14 | } 15 | 16 | type Loader func(api.AWSAPI) (Driver, error) 17 | -------------------------------------------------------------------------------- /pkg/sql/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/grafana/grafana-plugin-sdk-go/backend" 5 | "github.com/grafana/sqlds/v4" 6 | ) 7 | 8 | type Settings interface { 9 | Load(backend.DataSourceInstanceSettings) error 10 | Apply(args sqlds.Options) 11 | } 12 | 13 | type Loader func() Settings 14 | 15 | const DefaultKey = "__default" 16 | -------------------------------------------------------------------------------- /pkg/sql/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/grafana/grafana-aws-sdk/pkg/sql/api" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 11 | "github.com/grafana/sqlds/v4" 12 | ) 13 | 14 | type ResourceHandler struct { 15 | API api.Resources 16 | } 17 | 18 | func Write(rw http.ResponseWriter, b []byte) { 19 | _, err := rw.Write(b) 20 | if err != nil { 21 | log.DefaultLogger.Error(err.Error()) 22 | } 23 | } 24 | 25 | func ParseBody(body io.ReadCloser) (sqlds.Options, error) { 26 | reqBody := sqlds.Options{} 27 | b, err := io.ReadAll(body) 28 | if err != nil { 29 | return nil, err 30 | } 31 | err = json.Unmarshal(b, &reqBody) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return reqBody, nil 36 | } 37 | 38 | func marshalError(rw http.ResponseWriter, err error, code int) { 39 | errBytes, marshalErr := json.Marshal(err.Error()) 40 | if marshalErr != nil { 41 | log.DefaultLogger.Debug(err.Error()) 42 | rw.WriteHeader(http.StatusInternalServerError) 43 | jsonErr, jsonMarshalErr := json.Marshal(err) 44 | if jsonMarshalErr != nil { 45 | log.DefaultLogger.Error(jsonMarshalErr.Error()) 46 | return 47 | } 48 | Write(rw, jsonErr) 49 | return 50 | } 51 | rw.WriteHeader(code) 52 | Write(rw, errBytes) 53 | } 54 | 55 | func SendResources(rw http.ResponseWriter, res interface{}, err error) { 56 | if err != nil { 57 | marshalError(rw, err, http.StatusBadRequest) 58 | return 59 | } 60 | bytes, err := json.Marshal(res) 61 | if err != nil { 62 | marshalError(rw, err, http.StatusInternalServerError) 63 | return 64 | } 65 | rw.Header().Add("Content-Type", "application/json") 66 | Write(rw, bytes) 67 | } 68 | 69 | func (r *ResourceHandler) regions(rw http.ResponseWriter, req *http.Request) { 70 | regions, err := r.API.Regions(req.Context()) 71 | SendResources(rw, regions, err) 72 | } 73 | 74 | func (r *ResourceHandler) databases(rw http.ResponseWriter, req *http.Request) { 75 | reqBody, err := ParseBody(req.Body) 76 | if err != nil { 77 | marshalError(rw, err, http.StatusBadRequest) 78 | return 79 | } 80 | res, err := r.API.Databases(req.Context(), reqBody) 81 | SendResources(rw, res, err) 82 | } 83 | 84 | func (r *ResourceHandler) cancel(rw http.ResponseWriter, req *http.Request) { 85 | reqBody, err := ParseBody(req.Body) 86 | if err != nil { 87 | marshalError(rw, err, http.StatusBadRequest) 88 | return 89 | } 90 | queryID := reqBody["queryId"] 91 | if queryID == "" { 92 | SendResources(rw, nil, fmt.Errorf("empty queryID")) 93 | return 94 | } 95 | err = r.API.CancelQuery(req.Context(), reqBody, queryID) 96 | SendResources(rw, "Successfully canceled", err) 97 | } 98 | 99 | func (r *ResourceHandler) DefaultRoutes() map[string]func(http.ResponseWriter, *http.Request) { 100 | return map[string]func(http.ResponseWriter, *http.Request){ 101 | "/regions": r.regions, 102 | "/databases": r.databases, 103 | "/cancel": r.cancel, 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /pkg/sql/routes/routes_test.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/grafana/sqlds/v4" 14 | ) 15 | 16 | func TestWrite(t *testing.T) { 17 | rw := httptest.NewRecorder() 18 | msg := []byte("foo") 19 | Write(rw, msg) 20 | resp := rw.Result() 21 | body, err := io.ReadAll(resp.Body) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | if !cmp.Equal(body, msg) { 26 | t.Errorf("unexpected result: %v", cmp.Diff(body, msg)) 27 | } 28 | } 29 | 30 | func TestSendResources(t *testing.T) { 31 | rw := httptest.NewRecorder() 32 | SendResources(rw, []string{"foo"}, nil) 33 | resp := rw.Result() 34 | body, err := io.ReadAll(resp.Body) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | expected := []byte(`["foo"]`) 39 | if !cmp.Equal(body, expected) { 40 | t.Errorf("unexpected result: %v", cmp.Diff(body, expected)) 41 | } 42 | if rw.Header().Get("Content-Type") != "application/json" { 43 | t.Errorf("unexpected Content-Type header: %s", rw.Header().Get("Content-Type")) 44 | } 45 | } 46 | 47 | type fakeDS struct { 48 | regions []string 49 | databases map[string][]string 50 | } 51 | 52 | var ds = &fakeDS{ 53 | regions: []string{"us-east1"}, 54 | databases: map[string][]string{ 55 | "us-east-1": {"db1"}, 56 | }, 57 | } 58 | 59 | func (f *fakeDS) Regions(aws.Context) ([]string, error) { 60 | return f.regions, nil 61 | } 62 | 63 | func (f *fakeDS) Databases(ctx aws.Context, options sqlds.Options) ([]string, error) { 64 | dbs, ok := f.databases[options["region"]] 65 | if !ok { 66 | return nil, fmt.Errorf("error") 67 | } 68 | return dbs, nil 69 | } 70 | 71 | func (f *fakeDS) CancelQuery(ctx aws.Context, options sqlds.Options, queryID string) error { 72 | return nil 73 | } 74 | func TestDefaultRoutes(t *testing.T) { 75 | tests := []struct { 76 | description string 77 | route string 78 | reqBody []byte 79 | expectedCode int 80 | expectedResult string 81 | }{ 82 | { 83 | description: "return default regions", 84 | route: "/regions", 85 | reqBody: nil, 86 | expectedCode: http.StatusOK, 87 | expectedResult: `["us-east1"]`, 88 | }, 89 | { 90 | description: "default databases", 91 | route: "/databases", 92 | reqBody: []byte(`{"region":"us-east-1"}`), 93 | expectedCode: http.StatusOK, 94 | expectedResult: `["db1"]`, 95 | }, 96 | { 97 | description: "wrong region for databases", 98 | route: "/databases", 99 | reqBody: []byte(`{"region":"us-east-3"}`), 100 | expectedCode: http.StatusBadRequest, 101 | }, 102 | { 103 | description: "cancel query", 104 | route: "/cancel", 105 | reqBody: []byte(`{"queryId":"blah"}`), 106 | expectedCode: http.StatusOK, 107 | expectedResult: `"Successfully canceled"`, 108 | }, 109 | { 110 | description: "no queryId for cancel", 111 | route: "/cancel", 112 | reqBody: []byte(`{"region":"us-east-1"}`), 113 | expectedCode: http.StatusBadRequest, 114 | }, 115 | } 116 | for _, tt := range tests { 117 | t.Run(tt.description, func(t *testing.T) { 118 | req := httptest.NewRequest("GET", "http://example.com/foo", bytes.NewReader(tt.reqBody)) 119 | rw := httptest.NewRecorder() 120 | rh := ResourceHandler{API: ds} 121 | routes := rh.DefaultRoutes() 122 | routes[tt.route](rw, req) 123 | 124 | resp := rw.Result() 125 | body, err := io.ReadAll(resp.Body) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | if resp.StatusCode != tt.expectedCode { 131 | t.Errorf("expecting code %v got %v", tt.expectedCode, resp.StatusCode) 132 | } 133 | if resp.StatusCode == http.StatusOK && !cmp.Equal(string(body), tt.expectedResult) { 134 | t.Errorf("unexpected response: %v", cmp.Diff(string(body), tt.expectedResult)) 135 | } 136 | }) 137 | } 138 | } 139 | --------------------------------------------------------------------------------