Skip to content

Commit

Permalink
chore: merge master
Browse files Browse the repository at this point in the history
  • Loading branch information
wmontgomery committed Sep 10, 2022
2 parents 81ada08 + 748ecfb commit 4b559b3
Show file tree
Hide file tree
Showing 23 changed files with 4,511 additions and 3,142 deletions.
2 changes: 2 additions & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"main": "background.js",
"dependencies": {
"@aws-sdk/client-redshift": "^3.145.0",
"axios": "^0.21.4",
"axios-retry": "^3.2.4",
"base64-url": "^2.3.3",
Expand Down Expand Up @@ -89,6 +90,7 @@
"yargs-parser": "^20.2.7"
},
"devDependencies": {
"@aws-sdk/types": "^3.127.0",
"@types/better-sqlite3": "^5.4.1",
"@types/bytes": "^3.1.0",
"@types/codemirror": "^0.0.97",
Expand Down
13 changes: 13 additions & 0 deletions apps/studio/src/common/appdb/models/saved_connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export const ConnectionTypes = [
{ name: 'Oracle (ultimate)', value: 'other'}
]

export interface RedshiftOptions {
iamAuthenticationEnabled?: boolean
accessKeyId?: string;
secretAccessKey?: string;
awsRegion?: string;
clusterIdentifier?: string;
databaseGroup?: string;
tokenDurationSeconds?: number;
}

export interface ConnectionOptions {
cluster?: string
}
Expand Down Expand Up @@ -166,6 +176,9 @@ export class DbConnectionBase extends ApplicationEntity {
@Column({type: 'simple-json', nullable: false})
options: ConnectionOptions = {}

@Column({type: 'simple-json', nullable: false})
redshiftOptions: RedshiftOptions = {}

// this is only for SQL Server.
@Column({type: 'boolean', nullable: false})
trustServerCertificate = false
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/common/interfaces/IConnection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RedshiftOptions } from "../appdb/models/saved_connection"

export type ConnectionType = 'sqlite' | 'sqlserver' | 'redshift' | 'cockroachdb' | 'mysql' | 'postgresql' | 'mariadb' | 'cassandra'
export type SshMode = null | 'agent' | 'userpass' | 'keyfile'
Expand Down Expand Up @@ -29,6 +30,7 @@ export interface ISimpleConnection {
labelColor?: Nullable<string>
trustServerCertificate?: boolean
options?: any
redshiftOptions?: RedshiftOptions
}

export interface IConnection extends ISimpleConnection {
Expand Down
5 changes: 3 additions & 2 deletions apps/studio/src/components/ConnectionInterface.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<postgres-form v-if="config.connectionType === 'cockroachdb'" :config="config" :testing="testing"></postgres-form>
<mysql-form v-if="['mysql', 'mariadb'].includes(config.connectionType)" :config="config" :testing="testing" @save="save" @test="testConnection" @connect="submit"></mysql-form>
<postgres-form v-if="config.connectionType === 'postgresql'" :config="config" :testing="testing"></postgres-form>
<postgres-form v-if="config.connectionType === 'redshift'" :config="config" :testing="testing"></postgres-form>
<redshift-form v-if="config.connectionType === 'redshift'" :config="config" :testing="testing"></redshift-form>
<sqlite-form v-if="config.connectionType === 'sqlite'" :config="config" :testing="testing"></sqlite-form>
<sql-server-form v-if="config.connectionType === 'sqlserver'" :config="config" :testing="testing"></sql-server-form>
<other-database-notice v-if="config.connectionType === 'other'" />
Expand Down Expand Up @@ -67,6 +67,7 @@
import ConnectionSidebar from './sidebar/ConnectionSidebar'
import MysqlForm from './connection/MysqlForm'
import PostgresForm from './connection/PostgresForm'
import RedshiftForm from './connection/RedshiftForm'
import Sidebar from './common/Sidebar'
import SqliteForm from './connection/SqliteForm'
import SqlServerForm from './connection/SqlServerForm'
Expand All @@ -86,7 +87,7 @@ import OtherDatabaseNotice from './connection/OtherDatabaseNotice.vue'
// import ImportUrlForm from './connection/ImportUrlForm';
export default {
components: { ConnectionSidebar, MysqlForm, PostgresForm, Sidebar, SqliteForm, SqlServerForm, SaveConnectionForm, ImportButton, ErrorAlert, OtherDatabaseNotice, },
components: { ConnectionSidebar, MysqlForm, PostgresForm, RedshiftForm, Sidebar, SqliteForm, SqlServerForm, SaveConnectionForm, ImportButton, ErrorAlert, OtherDatabaseNotice, },
data() {
return {
Expand Down
19 changes: 15 additions & 4 deletions apps/studio/src/components/CoreTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
</div>
<modal :name="modalName" class="beekeeper-modal vue-dialog sure header-sure" @opened="sureOpened" @closed="sureClosed" @before-open="beforeOpened">
<div class="dialog-content">
<div class="dialog-c-title">Really {{this.titleCaseAction}} <span class="tab-like"><tab-icon :tab="tabIcon" /> {{this.dbElement}}</span>?</div>
<div class="dialog-c-title">Really {{this.dbAction | titleCase}} <span class="tab-like"><tab-icon :tab="tabIcon" /> {{this.dbElement}}</span>?</div>
<p>This change cannot be undone</p>
</div>
<div class="vue-dialog-buttons">
Expand Down Expand Up @@ -126,6 +126,13 @@
},
watch: {
},
filters: {
titleCase: function (value) {
if (!value) return ''
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
}
},
computed: {
...mapState('tabs', { 'activeTab': 'active'}),
Expand Down Expand Up @@ -199,13 +206,18 @@
this.$nextTick(async() => {
if (this.dbAction.toLowerCase() === 'delete') {
await this.connection.dropElement(dbName, entityType?.toUpperCase(), schema)
// timeout is more about aesthetics so it doesn't refresh the table right away.
setTimeout(() => {
return setTimeout(() => {
this.$store.dispatch('updateTables')
this.$store.dispatch('updateRoutines')
}, 500)
}
if (this.dbAction.toLowerCase() === 'truncate') {
await this.connection.truncateElement(dbName, entityType?.toUpperCase(), schema)
}
})
},
beforeOpened() {
Expand Down Expand Up @@ -237,7 +249,6 @@
}
},
async setActiveTab(tab) {
console.log("setting active tab", tab)
await this.$store.dispatch('tabs/setActive', tab)
},
async addTab(item: OpenTab) {
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/TabQueryEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ import { FavoriteQuery } from '@/common/appdb/models/favorite_query'
lineNumbers: true,
mode: this.connection.connectionType in modes ? modes[this.connection.connectionType] : "text/x-sql",
theme: 'monokai',
extraKeys: {"Ctrl-Space": "autocomplete", "Cmd-Space": "autocomplete"},
extraKeys: {"Ctrl-Space": "autocomplete", "Cmd-Space": "autocomplete", "Shift-Tab": "indentLess"},
hint: CodeMirror.hint.sql,
hintOptions: this.hintOptions
})
Expand Down
72 changes: 71 additions & 1 deletion apps/studio/src/components/connection/RedshiftForm.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,73 @@
<template>
<div>TBD</div>
<div class="with-connection-type">
<common-server-inputs :config="config"></common-server-inputs>

<div class="advanced-connection-settings">
<h4 class="advanced-heading flex" :class="{enabled: iamAuthenticationEnabled}">
<span class="expand">IAM Authentication</span>
<x-switch @click.prevent="toggleIAMAuthentication" :toggled="iamAuthenticationEnabled"></x-switch>
</h4>
<div class="advanced-body" v-show="iamAuthenticationEnabled">
<div class="row gutter">
<div class="alert alert-info">
<i class="material-icons-outlined">info</i>
<div>Use AWS IAM authentication to connect with temporary cluster credentials. <a href="https://docs.aws.amazon.com/redshift/latest/mgmt/generating-user-credentials.html">Read More</a></div>
</div>
</div>

<div class="form-group">
<label for="AWS Region">
AWS Region
</label>
<input type="text" class="form-control" v-model="config.redshiftOptions.awsRegion"/>
</div>
<div class="form-group">
<label for="Access Key ID">
Access Key ID
</label>
<input type="text" class="form-control" v-model="config.redshiftOptions.accessKeyId"/>
</div>
<div class="form-group">
<label for="Secret Access Key">
Secret Access Key
</label>
<input type="password" class="form-control" v-model="config.redshiftOptions.secretAccessKey"/>
</div>
<div class="form-group">
<label for="Cluster Identifier">Cluster Identifier</label>
<input type="text" class="form-control" v-model="config.redshiftOptions.clusterIdentifier"/>
</div>
<div class="form-group">
<label for="Database Group">Database Group <span class="hint">(optional)</span></label>
<input type="text" class="form-control" v-model="config.redshiftOptions.databaseGroup"/>
</div>
<div class="form-group">
<label for="Token Duration">Token Duration <span class="hint">(optional, in seconds)</span></label>
<input type="text" class="form-control" v-model="config.redshiftOptions.tokenDurationSeconds"/>
</div>
</div>
</div>

<common-advanced :config="config"></common-advanced>
</div>
</template>
<script>
import CommonServerInputs from './CommonServerInputs'
import CommonAdvanced from './CommonAdvanced'
export default {
components: { CommonServerInputs, CommonAdvanced },
data() {
return {
iamAuthenticationEnabled: this.config.redshiftOptions?.iamAuthenticationEnabled || false
}
},
methods: {
toggleIAMAuthentication() {
this.config.redshiftOptions.iamAuthenticationEnabled = this.iamAuthenticationEnabled = !this.iamAuthenticationEnabled
}
},
props: ['config'],
}
</script>
4 changes: 2 additions & 2 deletions apps/studio/src/components/tableview/ColumnFilterModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
</modal>
</template>

<style lang="scss">
<style lang="scss" scoped>
.modal-form {
margin-top: 0.25rem;
}
Expand Down Expand Up @@ -187,4 +187,4 @@
},
},
}
</script>
</script>
3 changes: 2 additions & 1 deletion apps/studio/src/lib/connection-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export default {
sslKeyFile: config.sslKeyFile,
sslRejectUnauthorized: config.sslRejectUnauthorized,
trustServerCertificate: config.trustServerCertificate,
options: config.options
options: config.options,
redshiftOptions: config.redshiftOptions
}
},

Expand Down
129 changes: 129 additions & 0 deletions apps/studio/src/lib/db/authentication/amazon-redshift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { GetClusterCredentialsCommand, RedshiftClient } from '@aws-sdk/client-redshift';

// The number of minutes to consider credentials expired *before* their actual expiration.
// This accounts for potential client clock drift.
const CREDENTIAL_EXPIRATION_THRESHOLD_MINUTES = 5;

export interface AWSCredentials {
accessKeyId: string;
secretAccessKey: string;
}

export interface ClusterCredentialConfiguration {
awsRegion: string;
clusterIdentifier: string;
dbName: string;
dbUser: string;
dbGroup: string;
durationSeconds: number;
}

export interface TemporaryClusterCredentials {
dbUser: string;
dbPassword: string;
expiration: Date;
}

/**
* RedshiftCredentialResolver provides the ability to use temporary cluster credentials to access
* an Amazon Redshift cluster.
*
* See: https://docs.aws.amazon.com/redshift/latest/mgmt/generating-user-credentials.html
*/
export class RedshiftCredentialResolver {
// This class uses a singleton pattern to maintain internal state.
private static instance: RedshiftCredentialResolver;
private constructor() {}
public static getInstance(): RedshiftCredentialResolver {
if (!RedshiftCredentialResolver.instance) {
RedshiftCredentialResolver.instance = new RedshiftCredentialResolver();
}
return RedshiftCredentialResolver.instance;
}

private credentials: Map<string, TemporaryClusterCredentials> = new Map();

private getCacheKey(awsCreds: AWSCredentials, config: ClusterCredentialConfiguration): string {
return JSON.stringify({awsCreds, config});
}

/**
* Determines whether credentials managed by the resolver should be refreshed.
*
* @returns true if the credentials should be refreshed
*/
private shouldRefreshCredentials(credentials: TemporaryClusterCredentials): Boolean {
// If no credentials have been set, refresh.
if (!credentials) {
return true;
}

// Return true if the credentials have passed the cache expiration threshold period.
const expiration = credentials.expiration.getTime();
const now = new Date().getTime();
return now >= expiration - (CREDENTIAL_EXPIRATION_THRESHOLD_MINUTES * 60 * 1000);
}

/**
* Exchanges a set of AWS credentials and configuration for a temporary set of credentials
* to a Redshift cluster.
*
* @param awsCredentials the AWS credentials
* @param config the credential configuration
* @returns the temporary credentials
*/
async getClusterCredentials(awsCredentials: AWSCredentials, config: ClusterCredentialConfiguration): Promise<TemporaryClusterCredentials> {
// Validate that all required fields have been provided
if (!awsCredentials.accessKeyId) {
throw new Error('Please provide an Access Key ID for IAM authentication.');
}
if (!awsCredentials.secretAccessKey) {
throw new Error('Please provide a Secret Access Key for IAM authentication.');
}
if (!config.awsRegion) {
throw new Error('Please provide an AWS Region for IAM authentication.');
}
if (!config.clusterIdentifier) {
throw new Error('Please provide a Cluster Identifier for IAM authentication.');
}

// Get any existing credentials
const cacheKey = this.getCacheKey(awsCredentials, config);
const credentials = this.credentials.get(cacheKey);

// If the credentials exist and were created <= credentialCacheSeconds ago, return them
// instead of refreshing. This prevents excessive calling to Redshift's control plane
// when we have credentials that we know with high confidence are still valid.
if (!this.shouldRefreshCredentials(credentials)) {
console.log(`Re-using existing Redshift cluster credentials.`);
return credentials;
}

// Construct the client
const redshiftClient = new RedshiftClient({
credentials: awsCredentials,
region: config.awsRegion
});

// Get the credentials
console.log(`Calling Redshift to get temporary cluster credentials with config ${JSON.stringify(config)}`)
const tempCredsResponse = await redshiftClient.send(new GetClusterCredentialsCommand({
ClusterIdentifier: config.clusterIdentifier,
DbName: config.dbName,
DbUser: config.dbUser,
DbGroups: [config.dbGroup],
DurationSeconds: config.durationSeconds || undefined,
AutoCreate: true
}));
console.log(`Redshift temporary cluster credentials will expire at ${tempCredsResponse.Expiration!}`)

const newCredentials = {
dbUser: tempCredsResponse.DbUser!,
dbPassword: tempCredsResponse.DbPassword!,
expiration: new Date(tempCredsResponse.Expiration!)
}
this.credentials.set(cacheKey, newCredentials);

return newCredentials;
}
}
Loading

0 comments on commit 4b559b3

Please sign in to comment.