-
Notifications
You must be signed in to change notification settings - Fork 2
CXH-2380: reach the DB2 native DSN form through connector config #149
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
Changes from all commits
a942195
209040e
640c973
dc98939
d03f4c5
66e11c1
a166e4c
4ca63a2
296d680
90883ac
ee2bd28
399f092
83a83bf
7a82463
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,12 +110,20 @@ Query parameters are forwarded as additional DB2 connection keywords | |
| (`HOSTNAME`, `DATABASE`, `PORT`, `PROTOCOL`, `UID`, `PWD`) are rejected — use the native | ||
| form below for full control. | ||
|
|
||
| DB2's native form is also accepted as-is: | ||
| DB2's native form is also accepted as-is (ODBC keywords are case-insensitive and may carry | ||
| spaces after each `;`): | ||
|
|
||
| ``` | ||
| HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP | ||
| ``` | ||
|
Comment on lines
+113
to
118
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this commit adds two user-visible constraints to the native form that the doc doesn't state. (1) |
||
|
|
||
| The native form is self-contained: it already carries the host, port, credentials, params and | ||
| target database. It is therefore mutually exclusive with the structured `connect` fields | ||
| (`host`, `port`, `user`, `password`, `params`) and with a per-database override (`connect.database` | ||
| or the `databases` block for multi-database sync). Combining them is rejected with an explicit | ||
| error rather than silently ignoring the extra settings, so use the `db2://` URL form when you | ||
| need multi-database discovery or want to supply fields separately. | ||
|
|
||
| ## Writing a Db2 spec | ||
|
|
||
| Db2 needs two things in every spec. Other engines need them only in spots (Oracle folds | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,39 +2,39 @@ package database | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| "github.com/conductorone/baton-sdk/pkg/uhttp" | ||||||||||||||||||||||||||||||||||||
| "github.com/conductorone/baton-sql/pkg/database/db2" | ||||||||||||||||||||||||||||||||||||
| "github.com/go-sql-driver/mysql" | ||||||||||||||||||||||||||||||||||||
| "google.golang.org/grpc/codes" | ||||||||||||||||||||||||||||||||||||
| "google.golang.org/grpc/status" | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const mysqlAccessDenied = 1045 | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // AuthError returns an Unauthenticated gRPC status when err is a database | ||||||||||||||||||||||||||||||||||||
| // authentication/authorization failure, or nil otherwise. name identifies the failing | ||||||||||||||||||||||||||||||||||||
| // database so a multi-DB config still shows which handle rejected the credentials. | ||||||||||||||||||||||||||||||||||||
| // SQLSTATE class 28 ("invalid authorization") is the ANSI code drivers report on bad | ||||||||||||||||||||||||||||||||||||
| // credentials (Postgres/Redshift/Vertica/etc. surface it via SQLState()); MySQL is the | ||||||||||||||||||||||||||||||||||||
| // exception, reporting error 1045 with no SQLSTATE. | ||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||
| // Coverage is limited to drivers that expose SQLState() plus MySQL. Drivers that do not | ||||||||||||||||||||||||||||||||||||
| // (Oracle go-ora, Db2 go_ibm_db, MSSQL, SAP HDB) fall through to nil, so their auth | ||||||||||||||||||||||||||||||||||||
| // failures reach the caller as a generic ping error rather than Unauthenticated. | ||||||||||||||||||||||||||||||||||||
| // AuthError wraps err in an Unauthenticated gRPC status naming the failing database when | ||||||||||||||||||||||||||||||||||||
| // err is a database auth failure, or returns nil otherwise, preserving the original error | ||||||||||||||||||||||||||||||||||||
| // via errors.As. Detection covers SQLSTATE class 28 (Postgres/Redshift), MySQL error 1045, | ||||||||||||||||||||||||||||||||||||
| // and DB2 (db2.IsAuthError); drivers without any of these (Vertica, Oracle, MSSQL, SAP HDB) | ||||||||||||||||||||||||||||||||||||
| // fall through to a generic ping error. | ||||||||||||||||||||||||||||||||||||
| func AuthError(err error, name string) error { | ||||||||||||||||||||||||||||||||||||
| if err == nil { | ||||||||||||||||||||||||||||||||||||
| if err == nil || !isAuthFailure(err) { | ||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return uhttp.WrapErrors(codes.Unauthenticated, fmt.Sprintf("database %q authentication failed", name), err) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| func isAuthFailure(err error) bool { | ||||||||||||||||||||||||||||||||||||
| var sqlState interface{ SQLState() string } | ||||||||||||||||||||||||||||||||||||
| if errors.As(err, &sqlState) && strings.HasPrefix(sqlState.SQLState(), "28") { | ||||||||||||||||||||||||||||||||||||
|
al-conductorone marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
baton-sql/vendor/github.com/ibmdb/go_ibm_db/error.go Lines 29 to 45 in 296d680
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done. |
||||||||||||||||||||||||||||||||||||
| return status.Errorf(codes.Unauthenticated, "database %q authentication failed", name) | ||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| var myErr *mysql.MySQLError | ||||||||||||||||||||||||||||||||||||
| if errors.As(err, &myErr) && myErr.Number == mysqlAccessDenied { | ||||||||||||||||||||||||||||||||||||
| return status.Errorf(codes.Unauthenticated, "database %q authentication failed", name) | ||||||||||||||||||||||||||||||||||||
| return true | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||
| return db2.IsAuthError(err) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,8 @@ import ( | |
| "github.com/conductorone/baton-sql/pkg/database/postgres" | ||
| "github.com/conductorone/baton-sql/pkg/database/sqlserver" | ||
| "github.com/conductorone/baton-sql/pkg/database/vertica" | ||
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/status" | ||
| ) | ||
|
|
||
| var DSNREnvRegex = regexp.MustCompile(`\$\{([A-Za-z0-9_]+)\}`) | ||
|
|
@@ -361,13 +363,25 @@ func ResolveDatabaseName(opts ConnectOptions) string { | |
| return expanded | ||
| } | ||
| } | ||
| if _, database, isNativeDB2, err := nativeDB2DSN(opts); err == nil && isNativeDB2 { | ||
| return database | ||
| } | ||
| parsedUrl, err := buildConnectionURL(opts) | ||
| if err != nil || parsedUrl == nil { | ||
| return "" | ||
| } | ||
| return strings.TrimPrefix(parsedUrl.Path, "/") | ||
| } | ||
|
|
||
| // hasStructuredConnectFields reports whether opts sets any structured connect field a | ||
| // self-contained native DB2 DSN would silently override; the caller rejects that | ||
| // combination instead of dropping the fields. Scheme is excluded, since "db2" alongside | ||
| // a native DSN is a supported hint. | ||
| func hasStructuredConnectFields(opts ConnectOptions) bool { | ||
| return opts.Host != "" || opts.Port != "" || opts.User != "" || | ||
| opts.Password != "" || opts.Database != "" || len(opts.Params) > 0 | ||
| } | ||
|
|
||
| // ConnectMany opens one *sql.DB per name in dbNames. On any per-database failure, | ||
| // every handle opened so far is closed before returning the error. | ||
| func ConnectMany(ctx context.Context, opts ConnectOptions, dbNames []string) (map[string]*sql.DB, DbEngine, error) { | ||
|
|
@@ -404,13 +418,38 @@ func ConnectMany(ctx context.Context, opts ConnectOptions, dbNames []string) (ma | |
| } | ||
|
|
||
| func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error) { | ||
| // A native DB2 DSN is opaque ODBC text, not a URL, so hand it to the driver verbatim | ||
| // instead of routing it through buildConnectionURL, which would corrupt it. See docs/db2.md. | ||
| nativeDSN, _, isNativeDB2, err := nativeDB2DSN(opts) | ||
| if err != nil { | ||
| return nil, Unknown, err | ||
| } | ||
| if isNativeDB2 { | ||
| // A native DSN already carries every connection setting, so structured fields or | ||
| // a per-database override would be silently dropped on the verbatim path; reject | ||
| // the combination instead of connecting to the wrong database. See docs/db2.md. | ||
| if hasStructuredConnectFields(opts) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: the mutual-exclusion check fires per- There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still open —
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, still there. low priority like you said. the combo is still rejected, just after a wasted admin connect and discovery run. |
||
| return nil, Unknown, status.Error(codes.InvalidArgument, | ||
| "native DB2 DSN is self-contained and cannot be combined with structured "+ | ||
| "connect fields (host, port, user, password, params) or a per-database "+ | ||
| "override (connect.database, databases); put every setting in the DSN or "+ | ||
| "use the db2:// URL form", | ||
| ) | ||
| } | ||
| db, err := db2.Connect(ctx, nativeDSN) | ||
| if err != nil { | ||
| return nil, Unknown, err | ||
| } | ||
| return db, DB2, nil | ||
| } | ||
|
|
||
| parsedDsn, err := buildConnectionURL(opts) | ||
| if err != nil { | ||
| return nil, Unknown, err | ||
| } | ||
|
|
||
| if parsedDsn.Scheme == "" { | ||
| return nil, Unknown, errors.New("database scheme must be specified in DSN or configuration") | ||
| return nil, Unknown, status.Error(codes.InvalidArgument, "database scheme must be specified in DSN or configuration") | ||
| } | ||
|
|
||
| switch parsedDsn.Scheme { | ||
|
|
@@ -456,7 +495,7 @@ func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error | |
| } | ||
| return db, Vertica, nil | ||
|
|
||
| case "db2": | ||
| case db2Scheme: | ||
| db, err := db2.Connect(ctx, parsedDsn.String()) | ||
| if err != nil { | ||
| return nil, Unknown, err | ||
|
|
@@ -468,6 +507,45 @@ func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error | |
| } | ||
| } | ||
|
|
||
| // db2Scheme is the "db2" scheme name, used both as the switch case above and to | ||
| // recognize an explicit (rather than inferred) DB2 hint in nativeDB2DSN. | ||
| const db2Scheme = "db2" | ||
|
|
||
| // nativeDB2DSN reports whether opts carries a native (ODBC keyword=value) DB2 DSN rather | ||
| // than a db2:// URL, returning the env-expanded DSN and its DATABASE value when it does. | ||
| // Detection defers to db2.ParseNativeDSN so this and convertToDB2DSN's passthrough share | ||
| // one decision. | ||
| func nativeDB2DSN(opts ConnectOptions) (string, string, bool, error) { | ||
| if opts.DSN == "" { | ||
| return "", "", false, nil | ||
| } | ||
| lookup := opts.resolveLookup() | ||
|
|
||
| scheme, err := expandValue(opts.Scheme, lookup) | ||
| if err != nil { | ||
| return "", "", false, err | ||
| } | ||
| if scheme != "" && scheme != db2Scheme { | ||
| return "", "", false, nil | ||
| } | ||
|
|
||
| dsn, err := expandValue(opts.DSN, lookup) | ||
|
al-conductorone marked this conversation as resolved.
|
||
| if err != nil { | ||
| return "", "", false, err | ||
| } | ||
| if _, native := db2.ParseNativeDSN(dsn); !native { | ||
| return "", "", false, nil | ||
| } | ||
| // Confirmed native: re-expand with keyword-injection validation. The expansion above | ||
| // only decides routing; the driver gets this string verbatim. | ||
| safeDSN, err := expandNativeDSN(opts.DSN, lookup) | ||
| if err != nil { | ||
| return "", "", false, err | ||
| } | ||
| database, _ := db2.ParseNativeDSN(safeDSN) | ||
| return safeDSN, database, true, nil | ||
| } | ||
|
|
||
| func buildConnectionURL(opts ConnectOptions) (*url.URL, error) { | ||
| var ( | ||
| parsedUrl *url.URL | ||
|
|
@@ -605,3 +683,39 @@ func expandValue(s string, lookup LookupFunc) (string, error) { | |
| } | ||
| return s, nil | ||
| } | ||
|
|
||
| // expandNativeDSN expands ${KEY} placeholders in a native DB2 DSN, rejecting any value | ||
| // containing an ODBC separator (; { } =); unlike the db2:// URL path, which quotes each | ||
| // field via quoteDB2Value, a native DSN reaches the driver verbatim, so an unchecked | ||
| // placeholder could inject or override keywords. | ||
| func expandNativeDSN(dsn string, lookup LookupFunc) (string, error) { | ||
| if !DSNREnvRegex.MatchString(dsn) { | ||
| return dsn, nil | ||
| } | ||
| // A DSN that is a single ${KEY} spanning the whole string is the full value, not a | ||
| // field embedded in literal structure, so its separators are legitimate: expand as-is. | ||
| if DSNREnvRegex.FindString(dsn) == dsn { | ||
| return expandValue(dsn, lookup) | ||
| } | ||
| if lookup == nil { | ||
| lookup = os.LookupEnv | ||
| } | ||
| var err error | ||
| result := DSNREnvRegex.ReplaceAllStringFunc(dsn, func(match string) string { | ||
| varName := match[2 : len(match)-1] | ||
| value, exists := lookup(varName) | ||
| if !exists { | ||
| err = errors.Join(err, fmt.Errorf("environment variable %s is not set", varName)) | ||
| return match | ||
| } | ||
| if strings.ContainsAny(value, ";{}=") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: rejecting |
||
| err = errors.Join(err, fmt.Errorf("value for %s must not contain ODBC keyword separators (; { } =)", varName)) | ||
| return match | ||
| } | ||
| return value | ||
| }) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return result, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| //go:build db2 | ||
|
|
||
| package db2 | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "testing" | ||
|
|
||
| "github.com/ibmdb/go_ibm_db" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestIsAuthError(t *testing.T) { | ||
| badCreds := &go_ibm_db.Error{Diag: []go_ibm_db.DiagRecord{{State: "28000"}}} | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| err error | ||
| want bool | ||
| }{ | ||
| {"class 28 bad credentials", badCreds, true}, | ||
| {"class 28 wrapped", fmt.Errorf("connect: %w", badCreds), true}, | ||
| {"non-auth sqlstate", &go_ibm_db.Error{Diag: []go_ibm_db.DiagRecord{{State: "42501"}}}, false}, | ||
| {"no diag records", &go_ibm_db.Error{}, false}, | ||
| {"unrelated error", fmt.Errorf("boom"), false}, | ||
| {"nil", nil, false}, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| require.Equal(t, tt.want, IsAuthError(tt.err)) | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,9 +5,11 @@ package db2 | |
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "errors" | ||
| "strings" | ||
| "time" | ||
|
|
||
| _ "github.com/ibmdb/go_ibm_db" | ||
| "github.com/ibmdb/go_ibm_db" | ||
| ) | ||
|
|
||
| // Connect establishes a connection to DB2 database. | ||
|
|
@@ -35,3 +37,18 @@ func Connect(ctx context.Context, dsn string) (*sql.DB, error) { | |
|
|
||
| return db, nil | ||
| } | ||
|
|
||
| // IsAuthError reports whether err is a DB2 auth failure. go_ibm_db exposes SQLSTATE | ||
| // via Error.Diag[].State, not a SQLState() method, so class 28 must be matched here. | ||
| func IsAuthError(err error) bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this real |
||
| var db2Err *go_ibm_db.Error | ||
| if !errors.As(err, &db2Err) { | ||
| return false | ||
| } | ||
| for _, rec := range db2Err.Diag { | ||
| if strings.HasPrefix(rec.State, "28") { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.