-
-
Notifications
You must be signed in to change notification settings - Fork 288
feat(plugins): add Snowflake database driver (#1420) #1580
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
2fda293
feat(plugins): add Snowflake database driver (#1420)
xantiagoma 4e90b05
feat(plugins): allow loading locally built plugins in DEBUG builds
xantiagoma 272bd1d
Merge branch 'main' into feat/snowflake-driver
xantiagoma aabf4c7
fix(plugins): implement create and drop database in the Snowflake driver
xantiagoma c4d6f86
fix(plugins): resolve the schema for schema-unaware metadata calls in…
xantiagoma 6103296
Merge remote-tracking branch 'origin/main' into feat/snowflake-driver
xantiagoma c30a5b1
Merge branch 'main' into feat/snowflake-driver
datlechin 75c3f12
fix(plugin-snowflake): poll long queries with backoff up to 45 minute…
datlechin 57a9f3b
fix(plugin-snowflake): trust the login session info for database and …
datlechin e9b2d32
feat(plugin-snowflake): server-side bindings, session heartbeat, SSO …
datlechin 3990029
feat(plugin-snowflake): warehouse-free metadata, key introspection, s…
datlechin 371ba1e
feat(plugins): session context pickers with Snowflake warehouse and r…
datlechin 6bb5f1d
fix(plugin-snowflake): share one session across pooled drivers, never…
datlechin e70ba83
fix(plugin-snowflake): coalesce shared-session re-login, scope the co…
datlechin f535675
Update project.pbxproj
datlechin 58b89d5
chore(plugin-snowflake): drop the one-time xcode bootstrap script and…
datlechin eedd2e0
ci(plugins): let the abi-additive label acknowledge a reviewed additi…
datlechin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>TableProMinAppVersion</key> | ||
| <string>0.48.0</string> | ||
| <key>TableProPluginKitVersion</key> | ||
| <integer>18</integer> | ||
| </dict> | ||
| </plist> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| // | ||
| // SnowflakeAuth.swift | ||
| // SnowflakeDriverPlugin | ||
| // | ||
| // Account identifier parsing, key-pair JWT generation, and | ||
| // ~/.snowflake/connections.toml parsing. | ||
| // | ||
|
|
||
| import CryptoKit | ||
| import Foundation | ||
| import os | ||
| import Security | ||
|
|
||
| enum SnowflakeAccount { | ||
| static func host(forAccount account: String) -> String { | ||
| let trimmed = account.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if trimmed.lowercased().hasSuffix(".snowflakecomputing.com") { | ||
| return trimmed | ||
| } | ||
| if trimmed.contains("://") { | ||
| return URL(string: trimmed)?.host ?? trimmed | ||
| } | ||
| return "\(trimmed).snowflakecomputing.com" | ||
| } | ||
|
|
||
| /// The account name used as the JWT issuer/subject prefix. Snowflake expects the | ||
| /// account locator without any region/cloud segment, uppercased. | ||
| static func issuerAccountName(forAccount account: String) -> String { | ||
| var name = account.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| if name.lowercased().hasSuffix(".snowflakecomputing.com") { | ||
| name = String(name.dropLast(".snowflakecomputing.com".count)) | ||
| } | ||
| if let dotIndex = name.firstIndex(of: ".") { | ||
| name = String(name[..<dotIndex]) | ||
| } | ||
| return name.uppercased() | ||
| } | ||
| } | ||
|
|
||
| struct SnowflakeKeyPairAuth { | ||
| private static let logger = Logger(subsystem: "com.TablePro", category: "SnowflakeKeyPairAuth") | ||
|
|
||
| let account: String | ||
| let user: String | ||
| let privateKeyPEM: String | ||
| let passphrase: String? | ||
|
|
||
| func makeJWT(lifetime: TimeInterval = 3_540) throws -> String { | ||
| let privateKey = try loadPrivateKey() | ||
| let qualifiedUser = "\(SnowflakeAccount.issuerAccountName(forAccount: account)).\(user.uppercased())" | ||
| let fingerprint = try publicKeyFingerprint(for: privateKey) | ||
| let issuer = "\(qualifiedUser).\(fingerprint)" | ||
|
|
||
| let now = Date() | ||
| let iat = Int(now.timeIntervalSince1970) | ||
| let exp = iat + Int(lifetime) | ||
|
|
||
| let headerJSON = #"{"alg":"RS256","typ":"JWT"}"# | ||
| let claimsJSON = #"{"iss":"\#(issuer)","sub":"\#(qualifiedUser)","iat":\#(iat),"exp":\#(exp)}"# | ||
|
|
||
| let signingInput = "\(base64URL(Data(headerJSON.utf8))).\(base64URL(Data(claimsJSON.utf8)))" | ||
| let signature = try sign(Data(signingInput.utf8), with: privateKey) | ||
| return "\(signingInput).\(base64URL(signature))" | ||
| } | ||
|
|
||
| private func loadPrivateKey() throws -> SecKey { | ||
| guard let pemData = privateKeyPEM.data(using: .utf8) else { | ||
| throw SnowflakeError.authFailed("Private key is not valid UTF-8") | ||
| } | ||
|
|
||
| var inputFormat = SecExternalFormat.formatUnknown | ||
| var itemType = SecExternalItemType.itemTypeUnknown | ||
| var importedItems: CFArray? | ||
|
|
||
| var keyParams = SecItemImportExportKeyParameters() | ||
| var passphraseRef: CFTypeRef? | ||
| if let passphrase, !passphrase.isEmpty { | ||
| let ref = passphrase as CFString | ||
| passphraseRef = ref | ||
| keyParams.passphrase = Unmanaged.passUnretained(ref) | ||
| } | ||
| _ = passphraseRef | ||
|
|
||
| let status = SecItemImport( | ||
| pemData as CFData, | ||
| "p8" as CFString, | ||
| &inputFormat, | ||
| &itemType, | ||
| SecItemImportExportFlags(rawValue: 0), | ||
| &keyParams, | ||
| nil, | ||
| &importedItems | ||
| ) | ||
|
|
||
| guard status == errSecSuccess, | ||
| let items = importedItems as? [SecKey], | ||
| let key = items.first | ||
| else { | ||
| throw SnowflakeError.authFailed( | ||
| "Failed to load private key (OSStatus \(status)). Ensure the file is a valid RSA .p8 key and the passphrase is correct." | ||
| ) | ||
| } | ||
| return key | ||
| } | ||
|
|
||
| private func publicKeyFingerprint(for privateKey: SecKey) throws -> String { | ||
| guard let publicKey = SecKeyCopyPublicKey(privateKey) else { | ||
| throw SnowflakeError.authFailed("Could not derive public key from private key") | ||
| } | ||
| var error: Unmanaged<CFError>? | ||
| guard let pkcs1 = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { | ||
| let message = error?.takeRetainedValue().localizedDescription ?? "unknown error" | ||
| throw SnowflakeError.authFailed("Could not export public key: \(message)") | ||
| } | ||
| let spki = Self.wrapPKCS1IntoSPKI(pkcs1) | ||
| let digest = SHA256.hash(data: spki) | ||
| return "SHA256:\(Data(digest).base64EncodedString())" | ||
| } | ||
|
|
||
| private func sign(_ data: Data, with key: SecKey) throws -> Data { | ||
| var error: Unmanaged<CFError>? | ||
| guard let signature = SecKeyCreateSignature( | ||
| key, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error | ||
| ) as Data? else { | ||
| let message = error?.takeRetainedValue().localizedDescription ?? "unknown error" | ||
| throw SnowflakeError.authFailed("Failed to sign JWT: \(message)") | ||
| } | ||
| return signature | ||
| } | ||
|
|
||
| private func base64URL(_ data: Data) -> String { | ||
| data.base64EncodedString() | ||
| .replacingOccurrences(of: "+", with: "-") | ||
| .replacingOccurrences(of: "/", with: "_") | ||
| .replacingOccurrences(of: "=", with: "") | ||
| } | ||
|
|
||
| /// Wrap a PKCS#1 RSAPublicKey DER blob into a SubjectPublicKeyInfo DER blob, | ||
| /// which is what Snowflake fingerprints with SHA-256. | ||
| static func wrapPKCS1IntoSPKI(_ pkcs1: Data) -> Data { | ||
| let rsaAlgorithmID: [UInt8] = [ | ||
| 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, | ||
| 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 | ||
| ] | ||
| var bitString: [UInt8] = [0x03] | ||
| bitString += derLength(pkcs1.count + 1) | ||
| bitString.append(0x00) | ||
| bitString += [UInt8](pkcs1) | ||
|
|
||
| var body = rsaAlgorithmID | ||
| body += bitString | ||
|
|
||
| var spki: [UInt8] = [0x30] | ||
| spki += derLength(body.count) | ||
| spki += body | ||
| return Data(spki) | ||
| } | ||
|
|
||
| private static func derLength(_ length: Int) -> [UInt8] { | ||
| if length < 0x80 { | ||
| return [UInt8(length)] | ||
| } | ||
| var value = length | ||
| var bytes: [UInt8] = [] | ||
| while value > 0 { | ||
| bytes.insert(UInt8(value & 0xFF), at: 0) | ||
| value >>= 8 | ||
| } | ||
| return [UInt8(0x80 | bytes.count)] + bytes | ||
| } | ||
| } | ||
|
|
||
| enum SnowflakeConnectionsTOML { | ||
| /// Look up the named connection in the Snowflake CLI's config files, checking | ||
| /// ~/.snowflake/connections.toml first, then [connections.*] sections in | ||
| /// ~/.snowflake/config.toml. Keys follow the CLI's snake_case naming | ||
| /// (account, user, password, authenticator, private_key_file, role, ...). | ||
| static func parameters(forConnection name: String) -> [String: String]? { | ||
| for filename in ["connections.toml", "config.toml"] { | ||
| let path = NSString(string: "~/.snowflake/\(filename)").expandingTildeInPath | ||
| guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue } | ||
| if let section = parse(contents)[name] { | ||
| return section | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| static func parse(_ contents: String) -> [String: [String: String]] { | ||
| var sections: [String: [String: String]] = [:] | ||
| var currentSection: String? | ||
|
|
||
| for rawLine in contents.components(separatedBy: .newlines) { | ||
| let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces) | ||
| if line.isEmpty { continue } | ||
|
|
||
| if line.hasPrefix("[") && line.hasSuffix("]") { | ||
| var name = String(line.dropFirst().dropLast()) | ||
| if name.hasPrefix("connections.") { | ||
| name = String(name.dropFirst("connections.".count)) | ||
| } | ||
| name = name.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) | ||
| currentSection = name | ||
| if sections[name] == nil { sections[name] = [:] } | ||
| continue | ||
| } | ||
|
|
||
| guard let section = currentSection, | ||
| let equalIndex = line.firstIndex(of: "=") else { continue } | ||
|
|
||
| let key = line[..<equalIndex].trimmingCharacters(in: .whitespaces) | ||
| let value = unquote(String(line[line.index(after: equalIndex)...]).trimmingCharacters(in: .whitespaces)) | ||
| sections[section]?[key] = value | ||
| } | ||
| return sections | ||
| } | ||
|
|
||
| private static func stripComment(_ line: String) -> String { | ||
| var inDoubleQuotes = false | ||
| var inSingleQuotes = false | ||
| var result = "" | ||
| for char in line { | ||
| if char == "\"" && !inSingleQuotes { inDoubleQuotes.toggle() } | ||
| if char == "'" && !inDoubleQuotes { inSingleQuotes.toggle() } | ||
| if char == "#" && !inDoubleQuotes && !inSingleQuotes { break } | ||
| result.append(char) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| private static func unquote(_ value: String) -> String { | ||
| if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { | ||
| return String(value.dropFirst().dropLast()) | ||
| } | ||
| if value.count >= 2, value.hasPrefix("'"), value.hasSuffix("'") { | ||
| return String(value.dropFirst().dropLast()) | ||
| } | ||
| return value | ||
| } | ||
| } | ||
39 changes: 39 additions & 0 deletions
39
Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| // | ||
| // SnowflakeBindingEncoder.swift | ||
| // SnowflakeDriverPlugin | ||
| // | ||
| // Encodes PluginCellValue parameters into the Snowflake v1 query-request | ||
| // "bindings" payload: 1-based string keys, TEXT for scalar values (the server | ||
| // coerces into the target column type), BINARY as hex, null as a typed null. | ||
| // | ||
|
|
||
| import Foundation | ||
| import TableProPluginKit | ||
|
|
||
| enum SnowflakeBindingEncoder { | ||
| static func encode(_ parameters: [PluginCellValue]) -> [String: [String: Any]] { | ||
| var bindings: [String: [String: Any]] = [:] | ||
| bindings.reserveCapacity(parameters.count) | ||
| for (index, parameter) in parameters.enumerated() { | ||
| bindings[String(index + 1)] = binding(for: parameter) | ||
| } | ||
| return bindings | ||
| } | ||
|
|
||
| private static func binding(for value: PluginCellValue) -> [String: Any] { | ||
| switch value { | ||
| case .null: | ||
| return ["type": "TEXT", "value": NSNull()] | ||
| case .text(let text): | ||
| return ["type": "TEXT", "value": text] | ||
| case .bytes(let data): | ||
| return ["type": "BINARY", "value": hex(data)] | ||
| default: | ||
| return ["type": "TEXT", "value": NSNull()] | ||
| } | ||
| } | ||
|
|
||
| private static func hex(_ data: Data) -> String { | ||
| data.map { String(format: "%02X", $0) }.joined() | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user enters a Snowflake account as a URL form that
host(forAccount:)accepts (for examplehttps://abc.snowflakecomputing.com/consoleor evenhttps://abc.snowflakecomputing.com), this helper runs the suffix/dot stripping against the raw URL string and returns values likeHTTPS://ABCforACCOUNT_NAME/JWT issuer. The request is then sent to the correct host but with an invalid Snowflake account name, so URL-form connections fail authentication; parse the URL host before stripping the Snowflake domain/region.Useful? React with 👍 / 👎.