From 33288aa8d38b53897c9926d599a3634c933c789c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Thu, 6 Feb 2025 18:01:17 +0100 Subject: [PATCH 01/23] KP auth for Snowflake --- .../src/Connection/Key_Pair_Credentials.enso | 13 ++++++++++++ .../src/Connection/Snowflake_Details.enso | 20 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso new file mode 100644 index 000000000000..344aae66fa7a --- /dev/null +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -0,0 +1,13 @@ +from Standard.Base import all + +type Key_Pair_Credentials + ## Credentials for key-pair based authentication. + + Arguments: + - username: The username to use for the connection. + - private_key: The private key to use for the connection. + It can either be a path to the private key file or an `Enso_Secret`. + The file must be located on the local machine. + - passphrase: The passphrase for the private key file. Only applicable if + the private key is a file. + Key_Pair username:Text private_key:Enso_Secret|File|Enso_File passphrase:Text|Nothing diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso index 74bd9767ee1f..36bbb1494c7e 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso @@ -2,6 +2,7 @@ from Standard.Base import all import Standard.Base.Data.Numbers.Number_Parse_Error import Standard.Base.Errors.Common.Missing_Argument import Standard.Base.Errors.Common.Type_Error +import Standard.Base.Errors.Illegal_Argument.Illegal_Argument import Standard.Base.Errors.Illegal_State.Illegal_State import Standard.Base.Metadata.Widget.Text_Input @@ -9,6 +10,7 @@ import Standard.Database.Connection.Connection_Options.Connection_Options import Standard.Database.Connection.Credentials.Credentials import Standard.Database.Internal.Data_Link_Setup.Data_Link_Setup +import project.Connection.Key_Pair_Credentials.Key_Pair_Credentials import project.Snowflake_Connection.Snowflake_Connection polyglot java import net.snowflake.client.jdbc.SnowflakeDriver @@ -24,7 +26,7 @@ type Snowflake_Details - warehouse: The name of the warehouse to use. @account (Text_Input display=..Always) @credentials Credentials.default_widget - Snowflake (account:Text=(Missing_Argument.throw "account")) (credentials:Credentials=(Missing_Argument.throw "credentials")) database:Text="SNOWFLAKE" schema:Text="PUBLIC" warehouse:Text="" + Snowflake (account:Text=(Missing_Argument.throw "account")) (credentials:Credentials|Key_Pair_Credentials=(Missing_Argument.throw "credentials")) database:Text="SNOWFLAKE" schema:Text="PUBLIC" warehouse:Text="" ## PRIVATE Attempt to resolve the constructor. @@ -64,7 +66,21 @@ type Snowflake_Details ## should be prepended to properties. That would make it fallback to plain `json`. account = [Pair.new 'account' self.account] - credentials = [Pair.new 'user' self.credentials.username, Pair.new 'password' self.credentials.password] + credentials = case self.credentials of + Credentials.Username_And_Password username password -> [Pair.new 'user' username, Pair.new 'password' password] + Key_Pair_Credentials.Key_Pair username private_key passphrase -> + key_part = case private_key of + local_file : File -> + passphrase_part = if passphrase != Nothing then [Pair.new 'private_key_file_pwd' passphrase] else [] + [Pair.new 'private_key_file' local_file.absolute.path] + passphrase_part + _ : Enso_File -> + Error.throw (Illegal_Argument.Error "Currently only local files can be used as private keys. In Cloud, consider using a Secret instead of a file.") + secret : Enso_Secret -> + if passphrase != Nothing then + Error.throw (Illegal_Argument.Error "Passphrase is not applicable when using a secret as a private key.") + [Pair.new 'privateKey' secret] + [Pair.new 'user' username] + key_part + database = [Pair.new 'db' self.database] schema = [Pair.new 'schema' self.schema] warehouse = if self.warehouse=="" then [] else [Pair.new 'warehouse' self.warehouse] From 0fc64cdb46eee69718289664ac29dd23216f95e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Thu, 6 Feb 2025 18:06:47 +0100 Subject: [PATCH 02/23] widget --- .../src/Connection/Snowflake_Details.enso | 3 ++- .../0.0.0-dev/src/Connection/Widgets.enso | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Widgets.enso diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso index 36bbb1494c7e..9ce0cdb60754 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso @@ -11,6 +11,7 @@ import Standard.Database.Connection.Credentials.Credentials import Standard.Database.Internal.Data_Link_Setup.Data_Link_Setup import project.Connection.Key_Pair_Credentials.Key_Pair_Credentials +import project.Connection.Widgets import project.Snowflake_Connection.Snowflake_Connection polyglot java import net.snowflake.client.jdbc.SnowflakeDriver @@ -25,7 +26,7 @@ type Snowflake_Details - schema: The name of the schema to connect to. - warehouse: The name of the warehouse to use. @account (Text_Input display=..Always) - @credentials Credentials.default_widget + @credentials Widgets.password_or_keypair_widget Snowflake (account:Text=(Missing_Argument.throw "account")) (credentials:Credentials|Key_Pair_Credentials=(Missing_Argument.throw "credentials")) database:Text="SNOWFLAKE" schema:Text="PUBLIC" warehouse:Text="" ## PRIVATE diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Widgets.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Widgets.enso new file mode 100644 index 000000000000..a15569f8424e --- /dev/null +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Widgets.enso @@ -0,0 +1,16 @@ +from Standard.Base import all +import Standard.Base.Metadata.Display +import Standard.Base.Metadata.Widget +from Standard.Base.Metadata.Choice import Option +from Standard.Base.Metadata.Widget import Single_Choice + +import Standard.Database.Connection.Credentials.Credentials + +import project.Connection.Key_Pair_Credentials.Key_Pair_Credentials + +## PRIVATE +password_or_keypair_widget display:Display=..When_Modified -> Widget = + basic_fqn = Credentials.to Meta.Type . qualified_name + key_pair_fqn = Key_Pair_Credentials.to Meta.Type . qualified_name + values = [Option "Username and Password" basic_fqn+".Username_And_Password", Option "Key Pair" key_pair_fqn+".Key_Pair"] + Single_Choice values=values display=display From 2aa7b4800d152a6d73a702ccad14d969c93cd029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Thu, 6 Feb 2025 18:11:29 +0100 Subject: [PATCH 03/23] required args --- .../0.0.0-dev/src/Connection/Key_Pair_Credentials.enso | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index 344aae66fa7a..01847618a705 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -1,4 +1,5 @@ from Standard.Base import all +import Standard.Base.Errors.Common.Missing_Argument type Key_Pair_Credentials ## Credentials for key-pair based authentication. @@ -10,4 +11,4 @@ type Key_Pair_Credentials The file must be located on the local machine. - passphrase: The passphrase for the private key file. Only applicable if the private key is a file. - Key_Pair username:Text private_key:Enso_Secret|File|Enso_File passphrase:Text|Nothing + Key_Pair username:Text=(Missing_Argument.throw "username") private_key:Enso_Secret|File|Enso_File=(Missing_Argument.throw "private_key") passphrase:Text|Nothing=Nothing From f83b0c938f646af23089c108d0033dab7304bb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Thu, 6 Feb 2025 23:59:51 +0100 Subject: [PATCH 04/23] WIP --- .../src/Connection/Key_Pair_Credentials.enso | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index 01847618a705..b09824e38a92 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -1,6 +1,7 @@ from Standard.Base import all import Standard.Base.Errors.Common.Missing_Argument +## Key-pair authentication for Snowflake. type Key_Pair_Credentials ## Credentials for key-pair based authentication. @@ -12,3 +13,32 @@ type Key_Pair_Credentials - passphrase: The passphrase for the private key file. Only applicable if the private key is a file. Key_Pair username:Text=(Missing_Argument.throw "username") private_key:Enso_Secret|File|Enso_File=(Missing_Argument.throw "private_key") passphrase:Text|Nothing=Nothing + + ## GROUP Output + ICON key + Generate a key pair that can be used for authentication. + + This function will generate a new key pair and store it in the provided + Cloud location. The private key will be stored as an `Enso_Secret` that + can be used when establishing a Database connection. The public key will + be saved as a text file next to that secret. + + To finalize the setup, the public key must be associated with your account. + To do so, you need to run a query like: + ALTER USER SET RSA_PUBLIC_KEY=; + + See https://docs.snowflake.com/en/user-guide/key-pair-auth#assign-the-public-key-to-a-snowflake-user for more information on the setup. + + Arguments: + - location: A directory where the secret and the public key will be stored. + Defaults to the user's home directory. + - name: The name of the secret that will store the private key. The + public key will be stored under the same name with the `.pub` extension. + generate_key_pair (location : Enso_File = Enso_File.home) (name : Text) -> Generated_Key_Pair + +## The result of `Key_Pair_Credentials.generate_key_pair`. + + It contains references to the generated secret and public key and can be + directly used when passing the private key to the credentials. +type Generated_Key_Pair + Value secret:Enso_Secret public_key:Enso_File From 664fc21c1c990d61f93116198a9df7f97fbfaa55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Fri, 7 Feb 2025 13:08:01 +0100 Subject: [PATCH 05/23] generating key pair --- .../src/Connection/Key_Pair_Credentials.enso | 60 ++++++++++++++++++- .../src/Internal/Key_Pair_Generator.enso | 27 +++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Key_Pair_Generator.enso diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index b09824e38a92..48c73ed1bfbd 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -1,5 +1,10 @@ from Standard.Base import all import Standard.Base.Errors.Common.Missing_Argument +import Standard.Base.Errors.Illegal_State.Illegal_State +import Standard.Base.Runtime.Context +from Standard.Base.Runtime import assert + +import project.Internal.Key_Pair_Generator ## Key-pair authentication for Snowflake. type Key_Pair_Credentials @@ -34,7 +39,45 @@ type Key_Pair_Credentials Defaults to the user's home directory. - name: The name of the secret that will store the private key. The public key will be stored under the same name with the `.pub` extension. - generate_key_pair (location : Enso_File = Enso_File.home) (name : Text) -> Generated_Key_Pair + - if_exists: Specifies what to do if the key pair with the given name + already exists. Defaults to warning the user and re-using the existing + key pair. + generate_key_pair (location : Enso_File = Enso_File.home) (name : Text) (if_exists:On_Existing_Key_Pair=..Use_Existing) -> Generated_Key_Pair = + public_key_file = location / (name + ".pub") + public_key_exists = public_key_file.exists + existing_secret = Enso_Secret.get name location + secret_exists = existing_secret.is_error.not + + ## Check consistency - if one part exist but the other does not, that is an error. + if public_key_exists && secret_exists.not then + Error.throw (Illegal_State.Error "The public key "+public_key_file.path+" already exists, but the corresponding secret does not. Please clean up any leftover keys or use a different name/location.") + if secret_exists && public_key_exists.not then + Error.throw (Illegal_State.Error "The secret "+name+" already exists, but the corresponding public key does not. Please clean up any leftover keys or use a different name/location.") + + assert (public_key_exists == secret_exists) + already_exists = secret_exists + + # Early return - if key pair exists and we should error. + if already_exists && (if_exists == On_Existing_Key_Pair.Error) then + Error.throw (Illegal_State.Error "The key pair with the name "+name+" already exists at the given location.") + + # Early return - if key pair exists and we should use it - we just return it. + if already_exists && (if_exists == On_Existing_Key_Pair.Use_Existing) then Generated_Key_Pair.Value existing_secret public_key_file else + ## Finally, we need to generate the key pair - because it didn't exist or we wanted to overwrite it. + assert ((if_exists == On_Existing_Key_Pair.Overwrite) || already_exists.not) + Context.Output.if_enabled disabled_message="A new key-pair can only be generated in Output mode. Press the Write button ▶ to perform the operation." panic=False <| + key_pair = Key_Pair_Generator.generate + + # Create/update the secret + secret = case secret_exists of + True -> existing_secret.update_value key_pair.raw_private_key + False -> Enso_Secret.create name key_pair.raw_private_key location + + # Create/update the public key + on_existing_file = if if_exists == On_Existing_Key_Pair.Overwrite then Existing_File_Behavior.Overwrite else Existing_File_Behavior.Error + key_pair.formatted_public_key.write public_key_file on_existing_file=on_existing_file + + Generated_Key_Pair.Value secret public_key_file ## The result of `Key_Pair_Credentials.generate_key_pair`. @@ -42,3 +85,18 @@ type Key_Pair_Credentials directly used when passing the private key to the credentials. type Generated_Key_Pair Value secret:Enso_Secret public_key:Enso_File + +## PRIVATE + Allows to pass `Generated_Key_Pair` directly into credentials, as an `Enso_Secret`. +Enso_Secret.from that:Generated_Key_Pair = that.secret + +## Specifies what to do if the key pair with the given name already exists. +type On_Existing_Key_Pair + ## Skips generation and returns the already existing key pair. + Use_Existing + + ## Generates a new key pair, overwriting the existing one. + Overwrite + + ## Throws an error if the key pair already exists. + Error diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Key_Pair_Generator.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Key_Pair_Generator.enso new file mode 100644 index 000000000000..80159cb5aaf0 --- /dev/null +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Internal/Key_Pair_Generator.enso @@ -0,0 +1,27 @@ +private + +from Standard.Base import all + +polyglot java import java.security.KeyPairGenerator +polyglot java import java.util.Base64 + +public_key_prefix = "-----BEGIN PUBLIC KEY-----" +public_key_suffix = "-----END PUBLIC KEY-----" + +type Key_Pair + ## A pair of RSA keys stored as base64 encoded strings. + Value raw_private_key:Text raw_public_key:Text + + formatted_public_key self -> Text = + crlf = '\r\n' + public_key_prefix + crlf + self.raw_public_key + crlf + public_key_suffix + crlf + +generate = + key_pair_generator = KeyPairGenerator.getInstance "RSA" + key_pair_generator.initialize 2048 + key_pair = key_pair_generator.generateKeyPair + + base64_encoder = Base64.getMimeEncoder + private_key = base64_encoder.encodeToString key_pair.getPrivate.getEncoded + public_key = base64_encoder.encodeToString key_pair.getPublic.getEncoded + Key_Pair.Value private_key public_key From 667af00cf222e553964f4b49b0b09775e36f19f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Fri, 7 Feb 2025 19:13:57 +0100 Subject: [PATCH 06/23] fix treating the secret as actual private key, working on vis --- .../src/Internal/JDBC_Connection.enso | 7 ++- .../src/Connection/Key_Pair_Credentials.enso | 35 ++++++++++-- .../src/Connection/Snowflake_Details.enso | 5 +- .../base/enso_cloud/EnsoSecretHelper.java | 11 +++- .../enso/base/enso_cloud/HideableValue.java | 5 +- .../enso_cloud/InterpretAsPrivateKey.java | 53 +++++++++++++++++++ .../base/enso_cloud/SecretValueResolver.java | 2 + 7 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java diff --git a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso index eeebb90e45aa..47e148af9fde 100644 --- a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso +++ b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso @@ -22,6 +22,7 @@ polyglot java import java.sql.DatabaseMetaData polyglot java import java.sql.PreparedStatement polyglot java import java.sql.SQLException polyglot java import java.sql.SQLTimeoutException +polyglot java import org.enso.base.enso_cloud.HideableValue polyglot java import org.enso.database.dryrun.OperationSynchronizer polyglot java import org.enso.database.JDBCProxy polyglot java import org.graalvm.collections.Pair as Java_Pair @@ -275,7 +276,11 @@ type JDBC_Connection create : Text -> Vector -> JDBC_Connection create url properties = handle_sql_errors <| java_props = properties.map pair-> - Java_Pair.create pair.first (as_hideable_value pair.second) + # Some parameters may be passed by the dialect as a `HideableValue` directly, so they do not need to be converted. + java_value = case pair.second of + already_java : HideableValue -> already_java + enso_value -> as_hideable_value enso_value + Java_Pair.create pair.first java_value java_connection = JDBCProxy.getConnection url java_props resource = Managed_Resource.register java_connection close_connection diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index 48c73ed1bfbd..3f235b3478cd 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -62,7 +62,7 @@ type Key_Pair_Credentials Error.throw (Illegal_State.Error "The key pair with the name "+name+" already exists at the given location.") # Early return - if key pair exists and we should use it - we just return it. - if already_exists && (if_exists == On_Existing_Key_Pair.Use_Existing) then Generated_Key_Pair.Value existing_secret public_key_file else + if already_exists && (if_exists == On_Existing_Key_Pair.Use_Existing) then Generated_Key_Pair.new existing_secret public_key_file else ## Finally, we need to generate the key pair - because it didn't exist or we wanted to overwrite it. assert ((if_exists == On_Existing_Key_Pair.Overwrite) || already_exists.not) Context.Output.if_enabled disabled_message="A new key-pair can only be generated in Output mode. Press the Write button ▶ to perform the operation." panic=False <| @@ -77,14 +77,43 @@ type Key_Pair_Credentials on_existing_file = if if_exists == On_Existing_Key_Pair.Overwrite then Existing_File_Behavior.Overwrite else Existing_File_Behavior.Error key_pair.formatted_public_key.write public_key_file on_existing_file=on_existing_file - Generated_Key_Pair.Value secret public_key_file + Generated_Key_Pair.new secret public_key_file ## The result of `Key_Pair_Credentials.generate_key_pair`. It contains references to the generated secret and public key and can be directly used when passing the private key to the credentials. type Generated_Key_Pair - Value secret:Enso_Secret public_key:Enso_File + private Value secret:Enso_Secret ~public_key_content:Text + + private new secret:Enso_Secret public_key_file:Enso_File = + lazy_read_file = + public_key_file.read_text Encoding.utf_8 ..Report_Error + Generated_Key_Pair.Value secret lazy_read_file + + ## PRIVATE + to_text self -> Text = "Generated_Key_Pair "+self.secret.name + to_display_text self -> Text = self.to_text + + ## Removes the prefix, suffix and line breaks from the public key. + private normalized_public_key self -> Text = + base = self.public_key_content.trim + if base.starts_with Key_Pair_Generator.public_key_prefix . not then + Error.throw (Illegal_State.Error "The public key does not start with the expected prefix: "+Key_Pair_Generator.public_key_prefix) + if base.ends_with Key_Pair_Generator.public_key_suffix . not then + Error.throw (Illegal_State.Error "The public key does not end with the expected suffix: "+Key_Pair_Generator.public_key_suffix) + trimmed = base + . drop (..First Key_Pair_Generator.public_key_prefix.length) + . drop (..Last Key_Pair_Generator.public_key_suffix.length) + trimmed.lines.join '' + + ## PRIVATE + to_js_object self = + text = 'To finish the setup you need to add the public key to your Snowflake account.\n' + + 'To do so, you can run the following command in the admin console:' + query = "ALTER USER SET RSA_PUBLIC_KEY='"+self.normalized_public_key+"';" + JS_Object.from_pairs [["text", text], ["query", query]] + ## PRIVATE Allows to pass `Generated_Key_Pair` directly into credentials, as an `Enso_Secret`. diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso index 9ce0cdb60754..4bf97ed10ff8 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso @@ -5,6 +5,7 @@ import Standard.Base.Errors.Common.Type_Error import Standard.Base.Errors.Illegal_Argument.Illegal_Argument import Standard.Base.Errors.Illegal_State.Illegal_State import Standard.Base.Metadata.Widget.Text_Input +from Standard.Base.Enso_Cloud.Enso_Secret import as_hideable_value import Standard.Database.Connection.Connection_Options.Connection_Options import Standard.Database.Connection.Credentials.Credentials @@ -14,6 +15,7 @@ import project.Connection.Key_Pair_Credentials.Key_Pair_Credentials import project.Connection.Widgets import project.Snowflake_Connection.Snowflake_Connection +polyglot java import org.enso.base.enso_cloud.InterpretAsPrivateKey polyglot java import net.snowflake.client.jdbc.SnowflakeDriver type Snowflake_Details @@ -79,7 +81,8 @@ type Snowflake_Details secret : Enso_Secret -> if passphrase != Nothing then Error.throw (Illegal_Argument.Error "Passphrase is not applicable when using a secret as a private key.") - [Pair.new 'privateKey' secret] + secret_as_private_key = InterpretAsPrivateKey.new (as_hideable_value secret) + [Pair.new 'privateKey' secret_as_private_key] [Pair.new 'user' username] + key_part database = [Pair.new 'db' self.database] diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/EnsoSecretHelper.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/EnsoSecretHelper.java index c618398b99c5..0f3fdad129b2 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/EnsoSecretHelper.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/EnsoSecretHelper.java @@ -7,6 +7,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest.Builder; import java.net.http.HttpResponse; +import java.security.PrivateKey; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; @@ -29,7 +30,15 @@ public static Connection getJDBCConnection( String url, List> properties) throws SQLException { var javaProperties = new Properties(); for (var pair : properties) { - javaProperties.setProperty(pair.getLeft(), resolveValue(pair.getRight())); + HideableValue value = pair.getRight(); + // Special handling for PrivateKey parameter. + if (value instanceof InterpretAsPrivateKey(HideableValue innerValue)) { + String rawKey = resolveValue(innerValue); + PrivateKey key = InterpretAsPrivateKey.decodePrivateKey(rawKey); + javaProperties.put(pair.getLeft(), key); + } else { + javaProperties.setProperty(pair.getLeft(), resolveValue(pair.getRight())); + } } return DriverManager.getConnection(url, javaProperties); diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java index 8f2cd9e8340e..66e93ac3f28b 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java @@ -5,10 +5,7 @@ /** Represents a value that is input of various operation that may contain a Secret. */ public sealed interface HideableValue - permits HideableValue.Base64EncodeValue, - HideableValue.ConcatValues, - HideableValue.PlainValue, - HideableValue.SecretValue { + permits HideableValue.Base64EncodeValue, HideableValue.ConcatValues, InterpretAsPrivateKey, HideableValue.PlainValue, HideableValue.SecretValue { record SecretValue(String secretId) implements HideableValue { @Override diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java new file mode 100644 index 000000000000..f59886b26369 --- /dev/null +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java @@ -0,0 +1,53 @@ +package org.enso.base.enso_cloud; + +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +public record InterpretAsPrivateKey(HideableValue value) implements HideableValue { + @Override + public String render() { + return ""; + } + + @Override + public String safeResolve() throws EnsoSecretAccessDenied { + throw new IllegalArgumentException("InterpretAsPrivateKey should only be used in context of JDBC."); + } + + @Override + public boolean containsSecrets() { + // We treat the private key as secret even if it is not passed as a secret value. + return true; + } + + static PrivateKey decodePrivateKey(String key) { + try { + KeyFactory factory = KeyFactory.getInstance("RSA"); + KeySpec spec = new PKCS8EncodedKeySpec(Base64.getMimeDecoder().decode(trimKey(key))); + return factory.generatePrivate(spec); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Unexpected: the JVM lacks support for RSA algorithm."); + } catch (InvalidKeySpecException e) { + throw new IllegalStateException("Encountered a private key is in invalid format."); + } + } + + private static String trimKey(String key) { + key = key.trim(); + if (key.startsWith(PRIVATE_KEY_PREFIX)) { + key = key.substring(PRIVATE_KEY_PREFIX.length()); + } + if (key.endsWith(PRIVATE_KEY_SUFFIX)) { + key = key.substring(0, key.length() - PRIVATE_KEY_SUFFIX.length()); + } + return key.trim(); + } + + private static final String PRIVATE_KEY_PREFIX = "-----BEGIN PRIVATE-----"; + private static final String PRIVATE_KEY_SUFFIX = "-----END PRIVATE-----"; +} diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java index 7207235be1ca..e382735d0456 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java @@ -20,6 +20,8 @@ protected static String resolveValue(HideableValue value) { } case HideableValue.Base64EncodeValue base64EncodeValue -> HideableValue.Base64EncodeValue .encode(resolveValue(base64EncodeValue.value())); + case InterpretAsPrivateKey pk -> + throw new IllegalStateException("InterpretAsPrivateKey can only be used in JDBC connections. This state should never be reached."); }; } } From 9b1aaee64d0072b8a6b141d07dc3452d9fc5f273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Fri, 7 Feb 2025 19:26:07 +0100 Subject: [PATCH 07/23] improve auth failure error --- .../src/Connection/Snowflake_Details.enso | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso index 4bf97ed10ff8..cd0378ba9c51 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso @@ -9,6 +9,7 @@ from Standard.Base.Enso_Cloud.Enso_Secret import as_hideable_value import Standard.Database.Connection.Connection_Options.Connection_Options import Standard.Database.Connection.Credentials.Credentials +import Standard.Database.Errors.SQL_Error import Standard.Database.Internal.Data_Link_Setup.Data_Link_Setup import project.Connection.Key_Pair_Credentials.Key_Pair_Credentials @@ -52,7 +53,8 @@ type Snowflake_Details make_new database schema warehouse = Snowflake_Details.Snowflake self.account self.credentials (database.if_nothing self.database) (schema.if_nothing self.schema) (warehouse.if_nothing self.warehouse) . connect options allow_data_links - Snowflake_Connection.create self.jdbc_url properties make_new data_link_setup + _enhance_connection_errors self <| + Snowflake_Connection.create self.jdbc_url properties make_new data_link_setup ## PRIVATE Provides the jdbc url for the connection. @@ -103,3 +105,13 @@ private create_data_link_structure details:Snowflake_Details data_link_location: warehouse_part = if details.warehouse.not_empty then [["warehouse", details.warehouse]] else [] credential_part = [["credentials", credentials_json]] header + connection_part + schema_part + warehouse_part + credential_part + +private _enhance_connection_errors (details:Snowflake_Details) ~action = + result = action + result.catch SQL_Error error-> + is_using_key_pair_auth = details.credentials.is_a Key_Pair_Credentials + message = error.java_exception.getMessage + is_likely_missing_public_key = is_using_key_pair_auth && message.contains "JWT token is invalid" + if is_likely_missing_public_key then + Error.throw (Illegal_State.Error "Authentication failed. Make sure you have associated the public key with your account and used correct username. The original error was: "+message cause=error) + result From ca7228ae692a9aea053fb78cfeb8e10dff606355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Fri, 7 Feb 2025 19:35:04 +0100 Subject: [PATCH 08/23] hack visualization --- .../0.0.0-dev/src/Connection/Key_Pair_Credentials.enso | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index 3f235b3478cd..dd57b61f9b37 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -112,7 +112,7 @@ type Generated_Key_Pair text = 'To finish the setup you need to add the public key to your Snowflake account.\n' + 'To do so, you can run the following command in the admin console:' query = "ALTER USER SET RSA_PUBLIC_KEY='"+self.normalized_public_key+"';" - JS_Object.from_pairs [["text", text], ["query", query]] + [text, query] ## PRIVATE From dca1c68e1b5bca39ed987013dce507cdb21ce60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Mon, 10 Feb 2025 14:20:46 +0100 Subject: [PATCH 09/23] add test for the full cloud flow --- .../src/Connection/Key_Pair_Credentials.enso | 15 +++++- test/Snowflake_Tests/src/Auth_Spec.enso | 54 +++++++++++++++++++ test/Snowflake_Tests/src/Snowflake_Spec.enso | 3 ++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 test/Snowflake_Tests/src/Auth_Spec.enso diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index dd57b61f9b37..f4b3df83a220 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -107,11 +107,24 @@ type Generated_Key_Pair . drop (..Last Key_Pair_Generator.public_key_suffix.length) trimmed.lines.join '' + ## Returns the query that should be run to associate the public key with the user. + + Arguments: + - username: Name of the user to associate the public key with. If not + provided, a placeholder will be used. + alter_user_query self (username:Text|Nothing = Nothing) -> Text = + username_part = case username of + Nothing -> "" + text : Text -> + escaped = text.replace '"' '""' + '"'+escaped+'"' + "ALTER USER "+username_part+" SET RSA_PUBLIC_KEY='"+self.normalized_public_key+"';" + ## PRIVATE to_js_object self = text = 'To finish the setup you need to add the public key to your Snowflake account.\n' + 'To do so, you can run the following command in the admin console:' - query = "ALTER USER SET RSA_PUBLIC_KEY='"+self.normalized_public_key+"';" + query = self.alter_user_query [text, query] diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso new file mode 100644 index 000000000000..c2d878827e53 --- /dev/null +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -0,0 +1,54 @@ +from Standard.Base import all + +from Standard.Database import all + +from Standard.Snowflake import all +import Standard.Snowflake.Connection.Key_Pair_Credentials.Key_Pair_Credentials + +from Standard.Test import all +import Standard.Test.Test_Environment + +import enso_dev.Base_Tests.Network.Enso_Cloud.Cloud_Tests_Setup.Cloud_Tests_Setup +import enso_dev.Base_Tests.Network.Enso_Cloud.Cloud_Tests_Setup.Temporary_Directory + +from project.Snowflake_Spec import get_configured_connection_details + +add_specs suite_builder ~default_connection = + suite_builder.group "[Snowflake] Key-pair authentication" group_builder-> + group_builder.specify "using local private key" <| + Error.throw "TODO" + group_builder.specify "using local private key encrypted with passkey" <| + Error.throw "TODO" + + cloud_setup = Cloud_Tests_Setup.prepare + test_root = Temporary_Directory.make "Snowflake-Auth" + group_builder.teardown test_root.cleanup + group_builder.specify "via the Cloud flow using secrets" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| + # Generate the key + generated_key = Key_Pair_Credentials.generate_key_pair location=test_root.get name="snowflake-key" if_exists=..Overwrite + generated_key.should_succeed + + # Create a temporary user and assign it the public key + user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) + default_connection.execute_update ('CREATE USER "' + user_name + '"') . should_succeed + Panic.with_finalizer (default_connection.execute_update ('DROP USER "' + user_name + '"')) <| + default_connection.execute_update (generated_key.alter_user_query user_name) . should_succeed + + # Try logging in with the key + key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=generated_key + base_details = get_configured_connection_details + new_details = Snowflake_Details.Snowflake base_details.account key_credentials base_details.database base_details.schema base_details.warehouse + new_connection = Database.connect new_details + Panic.with_finalizer new_connection.close <| + new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 + + group_builder.specify "correctly handle existing key files" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| + Error.throw "TODO" + + group_builder.specify "fail if existing key is broken" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| + Error.throw "TODO" + +main filter=Nothing = + suite = Test.build suite_builder-> + add_specs suite_builder (Database.connect get_configured_connection_details) + suite.run_with_filter filter diff --git a/test/Snowflake_Tests/src/Snowflake_Spec.enso b/test/Snowflake_Tests/src/Snowflake_Spec.enso index aced47d7bab0..9d4266e9293d 100644 --- a/test/Snowflake_Tests/src/Snowflake_Spec.enso +++ b/test/Snowflake_Tests/src/Snowflake_Spec.enso @@ -35,6 +35,8 @@ from enso_dev.Table_Tests.Common_Table_Operations.Util import all import enso_dev.Base_Tests.Network.Enso_Cloud.Cloud_Tests_Setup.Cloud_Tests_Setup +import project.Auth_Spec + type Snowflake_Info_Data Value ~data @@ -556,6 +558,7 @@ add_snowflake_specs suite_builder create_connection_fn db_name = Common_Table_Operations.Main.add_specs suite_builder setup Upload_Spec.add_specs suite_builder setup create_connection_fn IR_Spec.add_specs suite_builder setup prefix default_connection.get + Auth_Spec.add_specs suite_builder default_connection.get ## PRIVATE is_snowflake_integer value_type = case value_type of From 31e6caec6f6c45af2eb6794442d6a590f83705af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Mon, 10 Feb 2025 14:22:22 +0100 Subject: [PATCH 10/23] refactor: common temp user helper --- test/Snowflake_Tests/src/Auth_Spec.enso | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso index c2d878827e53..27ba7888bff9 100644 --- a/test/Snowflake_Tests/src/Auth_Spec.enso +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -29,9 +29,7 @@ add_specs suite_builder ~default_connection = generated_key.should_succeed # Create a temporary user and assign it the public key - user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) - default_connection.execute_update ('CREATE USER "' + user_name + '"') . should_succeed - Panic.with_finalizer (default_connection.execute_update ('DROP USER "' + user_name + '"')) <| + with_temp_user user_name-> default_connection.execute_update (generated_key.alter_user_query user_name) . should_succeed # Try logging in with the key @@ -48,6 +46,12 @@ add_specs suite_builder ~default_connection = group_builder.specify "fail if existing key is broken" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| Error.throw "TODO" +with_temp_user base_connection callback = + user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) + Panic.rethrow <| base_connection.execute_update ('CREATE USER "' + user_name + '"') + Panic.with_finalizer (base_connection.execute_update ('DROP USER "' + user_name + '"')) <| + callback user_name + main filter=Nothing = suite = Test.build suite_builder-> add_specs suite_builder (Database.connect get_configured_connection_details) From c32ab9159d8b0ba105ae3b0d22e48bd0027a23ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Mon, 10 Feb 2025 15:41:11 +0100 Subject: [PATCH 11/23] tests for existing key etc. --- .../src/Connection/Key_Pair_Credentials.enso | 16 +++++++--- test/Snowflake_Tests/src/Auth_Spec.enso | 31 +++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index f4b3df83a220..35207c3f7d5e 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -84,16 +84,24 @@ type Key_Pair_Credentials It contains references to the generated secret and public key and can be directly used when passing the private key to the credentials. type Generated_Key_Pair - private Value secret:Enso_Secret ~public_key_content:Text + private Value secret:Enso_Secret internal_file:Enso_File ~public_key_content:Text private new secret:Enso_Secret public_key_file:Enso_File = lazy_read_file = public_key_file.read_text Encoding.utf_8 ..Report_Error - Generated_Key_Pair.Value secret lazy_read_file + Generated_Key_Pair.Value secret public_key_file lazy_read_file + + ## Returns the file containing the public key. + public_key_file self -> Enso_File = + self.internal_file + + ## PRIVATE + to_text self -> Text = + "Generated_Key_Pair "+self.secret.name ## PRIVATE - to_text self -> Text = "Generated_Key_Pair "+self.secret.name - to_display_text self -> Text = self.to_text + to_display_text self -> Text = + self.to_text ## Removes the prefix, suffix and line breaks from the public key. private normalized_public_key self -> Text = diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso index 27ba7888bff9..1bf4f9c6b2ce 100644 --- a/test/Snowflake_Tests/src/Auth_Spec.enso +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -1,4 +1,5 @@ from Standard.Base import all +import Standard.Base.Errors.Illegal_State.Illegal_State from Standard.Database import all @@ -27,6 +28,7 @@ add_specs suite_builder ~default_connection = # Generate the key generated_key = Key_Pair_Credentials.generate_key_pair location=test_root.get name="snowflake-key" if_exists=..Overwrite generated_key.should_succeed + generated_key.public_key_file.exists . should_be_true # Create a temporary user and assign it the public key with_temp_user user_name-> @@ -41,10 +43,35 @@ add_specs suite_builder ~default_connection = new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 group_builder.specify "correctly handle existing key files" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| - Error.throw "TODO" + # Generate the first key + name = "snowflake-key-exist-check" + generated_key = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Error + generated_key.should_succeed + + remembered_public_key = generated_key.public_key_file.read_text + remembered_public_key.should_succeed + + # Second key with ..Error should fail + r2 = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Error + r2.should_fail_with Illegal_State + r2.to_display_text.should_contain "already exists" + + # Second key with ..Use_Existing will just return the current key + r3 = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Use_Existing + r3.should_succeed + r3.public_key_file.read_text.should_equal remembered_public_key + + # Second key with ..Overwrite will overwrite the key + r4 = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Overwrite + r4.should_succeed + r4.public_key_file.read_text.should_not_equal remembered_public_key group_builder.specify "fail if existing key is broken" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| - Error.throw "TODO" + name = "snowflake-key-broken" + "broken".write (test_root.get / (name + ".pub")) . should_succeed + r1 = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Use_Existing + r1.should_fail_with Illegal_State + with_temp_user base_connection callback = user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) From 50617fb641341046f9d973089401f8ef9521df8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 00:08:16 +0100 Subject: [PATCH 12/23] WIP key generation --- build.sbt | 13 +++++++++++ .../snowflake_helpers/TestKeyGenerator.java | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java diff --git a/build.sbt b/build.sbt index 8f7c1210e41b..d970e3c452cb 100644 --- a/build.sbt +++ b/build.sbt @@ -317,6 +317,7 @@ lazy val enso = (project in file(".")) `engine-runner`, `engine-runner-common`, `enso-test-java-helpers`, + `snowflake-test-java-helpers`, `exploratory-benchmark-java-helpers`, `fansi-wrapper`, filewatcher, @@ -4781,6 +4782,16 @@ lazy val `enso-test-java-helpers` = project .dependsOn(`std-base` % "provided") .dependsOn(`std-table` % "provided") +lazy val `snowflake-test-java-helpers` = project + .in(file("test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers")) + .settings( + frgaalJavaCompilerSetting, + autoScalaLibrary := false, + Compile / packageBin / artifactPath := + file("test/Snowflake_Tests/polyglot/java/snowflake-test-helpers.jar") + ) + .dependsOn(`std-snowflake` % "provided") + lazy val `exploratory-benchmark-java-helpers` = project .in( file( @@ -5484,6 +5495,7 @@ pkgStdLibInternal := Def.inputTask { (`std-table` / Compile / packageBin).value case "TestHelpers" => (`enso-test-java-helpers` / Compile / packageBin).value + (`snowflake-test-java-helpers` / Compile / packageBin).value (`exploratory-benchmark-java-helpers` / Compile / packageBin).value (`benchmark-java-helpers` / Compile / packageBin).value case "AWS" => @@ -5497,6 +5509,7 @@ pkgStdLibInternal := Def.inputTask { case _ if buildAllCmd => (`std-base` / Compile / packageBin).value (`enso-test-java-helpers` / Compile / packageBin).value + (`snowflake-test-java-helpers` / Compile / packageBin).value (`exploratory-benchmark-java-helpers` / Compile / packageBin).value (`benchmark-java-helpers` / Compile / packageBin).value (`std-table` / Compile / packageBin).value diff --git a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java new file mode 100644 index 000000000000..4c91dd0b527c --- /dev/null +++ b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java @@ -0,0 +1,23 @@ +package org.enso.snowflake_helpers; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; + +public class TestKeyGenerator { + public static void generateKeyPairForTest(String privateKeyPath, String publicKeyPath, String passphrase) throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Save the private key + try (FileOutputStream fileOutputStream = new FileOutputStream(privateKeyPath)) { + JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(fileOutputStream)); + } catch (IOException e) { + throw new RuntimeException("Failed to save the private key: " + e.getMessage(), e); + } + + } +} From 31be503308a0f11690314d10660317662442ddeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 11:01:32 +0100 Subject: [PATCH 13/23] testing with local key --- .../src/Connection/Key_Pair_Credentials.enso | 40 +++++++++-------- .../snowflake_helpers/TestKeyGenerator.java | 44 ++++++++++++++++--- test/Snowflake_Tests/src/Auth_Spec.enso | 44 +++++++++++++++---- 3 files changed, 96 insertions(+), 32 deletions(-) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso index 35207c3f7d5e..14e5f03a084c 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Key_Pair_Credentials.enso @@ -103,30 +103,13 @@ type Generated_Key_Pair to_display_text self -> Text = self.to_text - ## Removes the prefix, suffix and line breaks from the public key. - private normalized_public_key self -> Text = - base = self.public_key_content.trim - if base.starts_with Key_Pair_Generator.public_key_prefix . not then - Error.throw (Illegal_State.Error "The public key does not start with the expected prefix: "+Key_Pair_Generator.public_key_prefix) - if base.ends_with Key_Pair_Generator.public_key_suffix . not then - Error.throw (Illegal_State.Error "The public key does not end with the expected suffix: "+Key_Pair_Generator.public_key_suffix) - trimmed = base - . drop (..First Key_Pair_Generator.public_key_prefix.length) - . drop (..Last Key_Pair_Generator.public_key_suffix.length) - trimmed.lines.join '' - ## Returns the query that should be run to associate the public key with the user. Arguments: - username: Name of the user to associate the public key with. If not provided, a placeholder will be used. alter_user_query self (username:Text|Nothing = Nothing) -> Text = - username_part = case username of - Nothing -> "" - text : Text -> - escaped = text.replace '"' '""' - '"'+escaped+'"' - "ALTER USER "+username_part+" SET RSA_PUBLIC_KEY='"+self.normalized_public_key+"';" + generate_alter_user_query username self.public_key_content ## PRIVATE to_js_object self = @@ -135,6 +118,27 @@ type Generated_Key_Pair query = self.alter_user_query [text, query] +## PRIVATE +private parse_public_key public_key_content:Text -> Text = + base = public_key_content.trim + if base.starts_with Key_Pair_Generator.public_key_prefix . not then + Error.throw (Illegal_State.Error "The public key does not start with the expected prefix: "+Key_Pair_Generator.public_key_prefix) + if base.ends_with Key_Pair_Generator.public_key_suffix . not then + Error.throw (Illegal_State.Error "The public key does not end with the expected suffix: "+Key_Pair_Generator.public_key_suffix) + trimmed = base + . drop (..First Key_Pair_Generator.public_key_prefix.length) + . drop (..Last Key_Pair_Generator.public_key_suffix.length) + trimmed.lines.join '' + +## PRIVATE +generate_alter_user_query username:Text|Nothing public_key_file_content:Text -> Text = + username_part = case username of + Nothing -> "" + text : Text -> + escaped = text.replace '"' '""' + '"'+escaped+'"' + normalized_public_key = parse_public_key public_key_file_content + "ALTER USER "+username_part+" SET RSA_PUBLIC_KEY='"+normalized_public_key+"';" ## PRIVATE Allows to pass `Generated_Key_Pair` directly into credentials, as an `Enso_Secret`. diff --git a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java index 4c91dd0b527c..a3ed2f0b212a 100644 --- a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java +++ b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java @@ -1,23 +1,55 @@ package org.enso.snowflake_helpers; +import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStreamWriter; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPEMWriter; public class TestKeyGenerator { - public static void generateKeyPairForTest(String privateKeyPath, String publicKeyPath, String passphrase) throws NoSuchAlgorithmException { + public static void generateKeyPairForTest( + String privateKeyPath, String publicKeyPath, String passphrase) + throws NoSuchAlgorithmException, IOException { + + File privateKeyFile = new File(privateKeyPath); + File publicKeyFile = new File(publicKeyPath); + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); KeyPair keyPair = keyPairGenerator.generateKeyPair(); - // Save the private key - try (FileOutputStream fileOutputStream = new FileOutputStream(privateKeyPath)) { - JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(fileOutputStream)); - } catch (IOException e) { - throw new RuntimeException("Failed to save the private key: " + e.getMessage(), e); + savePublicKey(keyPair.getPublic(), publicKeyFile); + if (passphrase == null) { + savePrivateKey(keyPair.getPrivate(), privateKeyFile); + } else { + savePrivateKeyEncrypted(keyPair.getPrivate(), privateKeyFile, passphrase); + } + } + + private static void savePublicKey(PublicKey key, File destination) throws IOException { + try (FileOutputStream fileOutputStream = new FileOutputStream(destination); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream); + JcaPEMWriter pemWriter = new JcaPEMWriter(outputStreamWriter)) { + pemWriter.writeObject(key); + pemWriter.flush(); } + } + + private static void savePrivateKey(PrivateKey key, File destination) throws IOException { + try (FileOutputStream fileOutputStream = new FileOutputStream(destination); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream); + JcaPEMWriter pemWriter = new JcaPEMWriter(outputStreamWriter)) { + pemWriter.writeObject(key); + pemWriter.flush(); + } + } + private static void savePrivateKeyEncrypted(PrivateKey key, File destination, String passphrase) { + throw new UnsupportedOperationException("TODO"); } } diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso index 1bf4f9c6b2ce..c13ed673adbf 100644 --- a/test/Snowflake_Tests/src/Auth_Spec.enso +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -5,6 +5,7 @@ from Standard.Database import all from Standard.Snowflake import all import Standard.Snowflake.Connection.Key_Pair_Credentials.Key_Pair_Credentials +from Standard.Snowflake.Connection.Key_Pair_Credentials import generate_alter_user_query from Standard.Test import all import Standard.Test.Test_Environment @@ -14,31 +15,48 @@ import enso_dev.Base_Tests.Network.Enso_Cloud.Cloud_Tests_Setup.Temporary_Direct from project.Snowflake_Spec import get_configured_connection_details +polyglot java import org.enso.snowflake_helpers.TestKeyGenerator + add_specs suite_builder ~default_connection = suite_builder.group "[Snowflake] Key-pair authentication" group_builder-> group_builder.specify "using local private key" <| - Error.throw "TODO" + key_pair = generate_local_key_pair passphrase=Nothing + private_key_file = key_pair.first + + with_temp_user user_name-> + default_connection.execute_update (generate_alter_user_query user_name key_pair.second.read_text) . should_succeed + + key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=private_key_file + new_connection = Database.connect (make_connection_details_with_credentials key_credentials) + Panic.with_finalizer new_connection.close <| + new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 + group_builder.specify "using local private key encrypted with passkey" <| - Error.throw "TODO" + passphrase = "not-a-safe-passphrase-1234" + key_pair = generate_local_key_pair passphrase=passphrase + private_key_file = key_pair.first + + with_temp_user user_name-> + default_connection.execute_update (generate_alter_user_query user_name key_pair.second.read_text) . should_succeed + + key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=private_key_file passphrase=passphrase + new_connection = Database.connect (make_connection_details_with_credentials key_credentials) + Panic.with_finalizer new_connection.close <| + new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 cloud_setup = Cloud_Tests_Setup.prepare test_root = Temporary_Directory.make "Snowflake-Auth" group_builder.teardown test_root.cleanup group_builder.specify "via the Cloud flow using secrets" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| - # Generate the key generated_key = Key_Pair_Credentials.generate_key_pair location=test_root.get name="snowflake-key" if_exists=..Overwrite generated_key.should_succeed generated_key.public_key_file.exists . should_be_true - # Create a temporary user and assign it the public key with_temp_user user_name-> default_connection.execute_update (generated_key.alter_user_query user_name) . should_succeed - # Try logging in with the key key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=generated_key - base_details = get_configured_connection_details - new_details = Snowflake_Details.Snowflake base_details.account key_credentials base_details.database base_details.schema base_details.warehouse - new_connection = Database.connect new_details + new_connection = Database.connect (make_connection_details_with_credentials key_credentials) Panic.with_finalizer new_connection.close <| new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 @@ -79,6 +97,16 @@ with_temp_user base_connection callback = Panic.with_finalizer (base_connection.execute_update ('DROP USER "' + user_name + '"')) <| callback user_name +generate_local_key_pair passphrase:Text|Nothing = + public_key_file = File.create_temporary_file "snowflake-key" ".pub" + private_key_file = File.create_temporary_file "snowflake-key" ".p8" + TestKeyGenerator.generateKeyPairForTest private_key_file.path public_key_file.path passphrase + Pair.new private_key_file public_key_file + +make_connection_details_with_credentials credentials = + base_details = get_configured_connection_details + Snowflake_Details.Snowflake base_details.account credentials base_details.database base_details.schema base_details.warehouse + main filter=Nothing = suite = Test.build suite_builder-> add_specs suite_builder (Database.connect get_configured_connection_details) From 175a5b5acfbaeae8c88e8ccab3b1c515e842ac0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 11:01:43 +0100 Subject: [PATCH 14/23] javafmt --- .../main/java/org/enso/base/enso_cloud/HideableValue.java | 6 +++++- .../org/enso/base/enso_cloud/InterpretAsPrivateKey.java | 3 ++- .../java/org/enso/base/enso_cloud/SecretValueResolver.java | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java index 66e93ac3f28b..05f78222f6ef 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/HideableValue.java @@ -5,7 +5,11 @@ /** Represents a value that is input of various operation that may contain a Secret. */ public sealed interface HideableValue - permits HideableValue.Base64EncodeValue, HideableValue.ConcatValues, InterpretAsPrivateKey, HideableValue.PlainValue, HideableValue.SecretValue { + permits HideableValue.Base64EncodeValue, + HideableValue.ConcatValues, + InterpretAsPrivateKey, + HideableValue.PlainValue, + HideableValue.SecretValue { record SecretValue(String secretId) implements HideableValue { @Override diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java index f59886b26369..ccd7cd4f9c08 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/InterpretAsPrivateKey.java @@ -16,7 +16,8 @@ public String render() { @Override public String safeResolve() throws EnsoSecretAccessDenied { - throw new IllegalArgumentException("InterpretAsPrivateKey should only be used in context of JDBC."); + throw new IllegalArgumentException( + "InterpretAsPrivateKey should only be used in context of JDBC."); } @Override diff --git a/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java b/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java index e382735d0456..cbdb6897a6f2 100644 --- a/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java +++ b/std-bits/base/src/main/java/org/enso/base/enso_cloud/SecretValueResolver.java @@ -20,8 +20,9 @@ protected static String resolveValue(HideableValue value) { } case HideableValue.Base64EncodeValue base64EncodeValue -> HideableValue.Base64EncodeValue .encode(resolveValue(base64EncodeValue.value())); - case InterpretAsPrivateKey pk -> - throw new IllegalStateException("InterpretAsPrivateKey can only be used in JDBC connections. This state should never be reached."); + case InterpretAsPrivateKey pk -> throw new IllegalStateException( + "InterpretAsPrivateKey can only be used in JDBC connections. This state should never be" + + " reached."); }; } } From e3c7ad41a802c02be7ffb075275a25f8ce42acd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 11:06:03 +0100 Subject: [PATCH 15/23] reorg --- .../snowflake_helpers/TestKeyGenerator.java | 3 +- test/Snowflake_Tests/src/Auth_Spec.enso | 37 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java index a3ed2f0b212a..d73b1d7aa1ca 100644 --- a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java +++ b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java @@ -23,12 +23,13 @@ public static void generateKeyPairForTest( keyPairGenerator.initialize(2048); KeyPair keyPair = keyPairGenerator.generateKeyPair(); - savePublicKey(keyPair.getPublic(), publicKeyFile); if (passphrase == null) { savePrivateKey(keyPair.getPrivate(), privateKeyFile); } else { savePrivateKeyEncrypted(keyPair.getPrivate(), privateKeyFile, passphrase); } + + savePublicKey(keyPair.getPublic(), publicKeyFile); } private static void savePublicKey(PublicKey key, File destination) throws IOException { diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso index c13ed673adbf..b8266bedb457 100644 --- a/test/Snowflake_Tests/src/Auth_Spec.enso +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -17,6 +17,24 @@ from project.Snowflake_Spec import get_configured_connection_details polyglot java import org.enso.snowflake_helpers.TestKeyGenerator + +with_temp_user base_connection callback = + user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) + Panic.rethrow <| base_connection.execute_update ('CREATE USER "' + user_name + '"') + Panic.with_finalizer (base_connection.execute_update ('DROP USER "' + user_name + '"')) <| + callback user_name + +generate_local_key_pair passphrase:Text|Nothing = + public_key_file = File.create_temporary_file "snowflake-key" ".pub" + private_key_file = File.create_temporary_file "snowflake-key" ".p8" + TestKeyGenerator.generateKeyPairForTest private_key_file.path public_key_file.path passphrase + IO.println public_key_file.read_text + Pair.new private_key_file public_key_file + +make_connection_details_with_credentials credentials = + base_details = get_configured_connection_details + Snowflake_Details.Snowflake base_details.account credentials base_details.database base_details.schema base_details.warehouse + add_specs suite_builder ~default_connection = suite_builder.group "[Snowflake] Key-pair authentication" group_builder-> group_builder.specify "using local private key" <| @@ -31,7 +49,7 @@ add_specs suite_builder ~default_connection = Panic.with_finalizer new_connection.close <| new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 - group_builder.specify "using local private key encrypted with passkey" <| + group_builder.specify "using local private key encrypted with passphrase" <| passphrase = "not-a-safe-passphrase-1234" key_pair = generate_local_key_pair passphrase=passphrase private_key_file = key_pair.first @@ -90,23 +108,6 @@ add_specs suite_builder ~default_connection = r1 = Key_Pair_Credentials.generate_key_pair location=test_root.get name=name if_exists=..Use_Existing r1.should_fail_with Illegal_State - -with_temp_user base_connection callback = - user_name = "temporary-test-user-" + (Date_Time.now.format "yyyy-MM-dd_HH-mm") + "-" + (Random.uuid.take 5) - Panic.rethrow <| base_connection.execute_update ('CREATE USER "' + user_name + '"') - Panic.with_finalizer (base_connection.execute_update ('DROP USER "' + user_name + '"')) <| - callback user_name - -generate_local_key_pair passphrase:Text|Nothing = - public_key_file = File.create_temporary_file "snowflake-key" ".pub" - private_key_file = File.create_temporary_file "snowflake-key" ".p8" - TestKeyGenerator.generateKeyPairForTest private_key_file.path public_key_file.path passphrase - Pair.new private_key_file public_key_file - -make_connection_details_with_credentials credentials = - base_details = get_configured_connection_details - Snowflake_Details.Snowflake base_details.account credentials base_details.database base_details.schema base_details.warehouse - main filter=Nothing = suite = Test.build suite_builder-> add_specs suite_builder (Database.connect get_configured_connection_details) From a26eb0ddda0528bb379e72da4bbc9b9dcf19b543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 11:30:40 +0100 Subject: [PATCH 16/23] test for encrypted key --- .../snowflake_helpers/TestKeyGenerator.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java index d73b1d7aa1ca..1b2a32551c88 100644 --- a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java +++ b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java @@ -9,12 +9,20 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.Security; + +import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; +import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.PKCS8Generator; import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPKCS8Generator; +import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8EncryptorBuilder; +import net.snowflake.client.jdbc.internal.org.bouncycastle.operator.OperatorCreationException; +import net.snowflake.client.jdbc.internal.org.bouncycastle.operator.OutputEncryptor; public class TestKeyGenerator { public static void generateKeyPairForTest( String privateKeyPath, String publicKeyPath, String passphrase) - throws NoSuchAlgorithmException, IOException { + throws NoSuchAlgorithmException, IOException, OperatorCreationException { File privateKeyFile = new File(privateKeyPath); File publicKeyFile = new File(publicKeyPath); @@ -50,7 +58,19 @@ private static void savePrivateKey(PrivateKey key, File destination) throws IOEx } } - private static void savePrivateKeyEncrypted(PrivateKey key, File destination, String passphrase) { - throw new UnsupportedOperationException("TODO"); + private static void savePrivateKeyEncrypted(PrivateKey key, File destination, String passphrase) + throws IOException, OperatorCreationException { + Security.addProvider(new BouncyCastleProvider()); + var encryptorBuilder = new JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.AES_256_CBC); + encryptorBuilder.setPassword(passphrase.toCharArray().clone()); + OutputEncryptor encryptor = encryptorBuilder.build(); + JcaPKCS8Generator pkcs8Generator = new JcaPKCS8Generator(key, encryptor); + + try (FileOutputStream fileOutputStream = new FileOutputStream(destination); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream); + JcaPEMWriter pemWriter = new JcaPEMWriter(outputStreamWriter)) { + pemWriter.writeObject(pkcs8Generator.generate()); + pemWriter.flush(); + } } } From fbb4267050261bf1a90a708c50316256e09b3878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 14:09:45 +0100 Subject: [PATCH 17/23] restructure test --- test/Snowflake_Tests/src/Auth_Spec.enso | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/Snowflake_Tests/src/Auth_Spec.enso b/test/Snowflake_Tests/src/Auth_Spec.enso index b8266bedb457..4598245d2d84 100644 --- a/test/Snowflake_Tests/src/Auth_Spec.enso +++ b/test/Snowflake_Tests/src/Auth_Spec.enso @@ -28,7 +28,6 @@ generate_local_key_pair passphrase:Text|Nothing = public_key_file = File.create_temporary_file "snowflake-key" ".pub" private_key_file = File.create_temporary_file "snowflake-key" ".p8" TestKeyGenerator.generateKeyPairForTest private_key_file.path public_key_file.path passphrase - IO.println public_key_file.read_text Pair.new private_key_file public_key_file make_connection_details_with_credentials credentials = @@ -41,7 +40,7 @@ add_specs suite_builder ~default_connection = key_pair = generate_local_key_pair passphrase=Nothing private_key_file = key_pair.first - with_temp_user user_name-> + with_temp_user default_connection user_name-> default_connection.execute_update (generate_alter_user_query user_name key_pair.second.read_text) . should_succeed key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=private_key_file @@ -54,13 +53,14 @@ add_specs suite_builder ~default_connection = key_pair = generate_local_key_pair passphrase=passphrase private_key_file = key_pair.first - with_temp_user user_name-> + table = with_temp_user default_connection user_name-> default_connection.execute_update (generate_alter_user_query user_name key_pair.second.read_text) . should_succeed key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=private_key_file passphrase=passphrase new_connection = Database.connect (make_connection_details_with_credentials key_credentials) Panic.with_finalizer new_connection.close <| - new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 + new_connection.query (..Raw_SQL "SELECT 2+3") . read + table.at 0 . at 0 . should_equal 5 cloud_setup = Cloud_Tests_Setup.prepare test_root = Temporary_Directory.make "Snowflake-Auth" @@ -70,13 +70,14 @@ add_specs suite_builder ~default_connection = generated_key.should_succeed generated_key.public_key_file.exists . should_be_true - with_temp_user user_name-> + table = with_temp_user default_connection user_name-> default_connection.execute_update (generated_key.alter_user_query user_name) . should_succeed key_credentials = Key_Pair_Credentials.Key_Pair username=user_name private_key=generated_key new_connection = Database.connect (make_connection_details_with_credentials key_credentials) Panic.with_finalizer new_connection.close <| - new_connection.query (..Raw_SQL "SELECT 2+3") . read . at 0 . at 0 . should_equal 5 + new_connection.query (..Raw_SQL "SELECT 2+3") . read + table.at 0 . at 0 . should_equal 5 group_builder.specify "correctly handle existing key files" pending=cloud_setup.real_cloud_pending <| cloud_setup.with_prepared_environment <| # Generate the first key From 56615d95e94225d12d031845b0836e79dc90696e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 14:09:54 +0100 Subject: [PATCH 18/23] better error message on unsupported key format --- .../0.0.0-dev/src/Connection/Snowflake_Details.enso | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso index cd0378ba9c51..d40be1d83a52 100644 --- a/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso +++ b/distribution/lib/Standard/Snowflake/0.0.0-dev/src/Connection/Snowflake_Details.enso @@ -114,4 +114,12 @@ private _enhance_connection_errors (details:Snowflake_Details) ~action = is_likely_missing_public_key = is_using_key_pair_auth && message.contains "JWT token is invalid" if is_likely_missing_public_key then Error.throw (Illegal_State.Error "Authentication failed. Make sure you have associated the public key with your account and used correct username. The original error was: "+message cause=error) + + is_using_cloud = case details.credentials of + Key_Pair_Credentials.Key_Pair _ private_key _ -> private_key.is_a Enso_Secret + _ -> False + is_invalid_private_key_format = message.contains "Private key provided is invalid or not supported" + if is_using_cloud.not && is_invalid_private_key_format then + Error.throw (Illegal_State.Error "The provided private key file is not supported by the Snowflake driver. We recommend generating the key using `generate_key_pair` method and storing it as a Secret in Enso Cloud. The original error was: "+message cause=error) + result From 8235c2c14b1216ff48ca74439b56db7f47eb2c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Tue, 11 Feb 2025 14:14:52 +0100 Subject: [PATCH 19/23] fmt --- .../main/java/org/enso/snowflake_helpers/TestKeyGenerator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java index 1b2a32551c88..9520b75d383e 100644 --- a/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java +++ b/test/Snowflake_Tests/polyglot-sources/snowflake-test-java-helpers/src/main/java/org/enso/snowflake_helpers/TestKeyGenerator.java @@ -10,7 +10,6 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.Security; - import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.PKCS8Generator; import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPEMWriter; From 5f6a3e9defa631c104d9cfc013d68e6116014b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Wed, 12 Feb 2025 17:10:14 +0100 Subject: [PATCH 20/23] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c4e05107fe..70d0b1270c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ columns instead of discarding them. - [Added DB_Table.Offset for SQLServer][12206] - [Added DB_Table.Offset for Snowflake, Postgres, SQLite][12251] +- [Support for key-pair authentication in Snowflake connector.][12247] [11926]: https://github.com/enso-org/enso/pull/11926 [12031]: https://github.com/enso-org/enso/pull/12031 @@ -60,6 +61,7 @@ [12231]: https://github.com/enso-org/enso/pull/12231 [12206]: https://github.com/enso-org/enso/pull/12206 [12251]: https://github.com/enso-org/enso/pull/12251 +[12247]: https://github.com/enso-org/enso/pull/12247 #### Enso Language & Runtime From c18a84d211ed052a15ea2fc293fa35731272c94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Wed, 12 Feb 2025 18:07:34 +0100 Subject: [PATCH 21/23] bump From c77d4f3e315a56e607010c00649fa4bbe87b21a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Wed, 12 Feb 2025 19:10:06 +0100 Subject: [PATCH 22/23] workaround for bug https://github.com/enso-org/enso/issues/12266 --- .../Database/0.0.0-dev/src/Internal/JDBC_Connection.enso | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso index 47e148af9fde..d1a68eacdd6d 100644 --- a/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso +++ b/distribution/lib/Standard/Database/0.0.0-dev/src/Internal/JDBC_Connection.enso @@ -277,9 +277,9 @@ create : Text -> Vector -> JDBC_Connection create url properties = handle_sql_errors <| java_props = properties.map pair-> # Some parameters may be passed by the dialect as a `HideableValue` directly, so they do not need to be converted. - java_value = case pair.second of - already_java : HideableValue -> already_java - enso_value -> as_hideable_value enso_value + value = pair.second + java_value = if value.is_a HideableValue then value else + as_hideable_value value Java_Pair.create pair.first java_value java_connection = JDBCProxy.getConnection url java_props From 69b2fe93222932a8fd8f487740330ac959b9cb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Thu, 13 Feb 2025 13:02:33 +0100 Subject: [PATCH 23/23] add missing dependency to ensure helper JAR is built --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index d970e3c452cb..80315ad829d2 100644 --- a/build.sbt +++ b/build.sbt @@ -2829,6 +2829,7 @@ lazy val runtime = (project in file("engine/runtime")) (Runtime / compile) := (Runtime / compile) .dependsOn(`std-base` / Compile / packageBin) .dependsOn(`enso-test-java-helpers` / Compile / packageBin) + .dependsOn(`snowflake-test-java-helpers` / Compile / packageBin) .dependsOn(`benchmark-java-helpers` / Compile / packageBin) .dependsOn(`exploratory-benchmark-java-helpers` / Compile / packageBin) .dependsOn(`std-image` / Compile / packageBin)