Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ jobs:
- name: Build baton-sql
run: go build ./cmd/baton-sql
- name: Run sync tests
uses: ConductorOne/github-workflows/actions/sync-test@v2
uses: ConductorOne/github-workflows/actions/sync-test@v4
Comment thread
al-conductorone marked this conversation as resolved.
with:
connector: ./baton-sql
baton-entitlement: 'role:admin:member'
baton-principal: john.smith
baton-principal-type: user
- name: Run account provisioning tests
uses: ConductorOne/github-workflows/actions/account-provisioning@v3
uses: ConductorOne/github-workflows/actions/account-provisioning@v4
with:
connector: ./baton-sql
account-email: robert.tables2@example.com
Expand Down
9 changes: 3 additions & 6 deletions cmd/baton-sql/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ package main

import (
"context"
"fmt"
"os"

configSdk "github.com/conductorone/baton-sdk/pkg/config"
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
"github.com/conductorone/baton-sdk/pkg/exit"
"github.com/conductorone/baton-sdk/pkg/field"
"github.com/conductorone/baton-sdk/pkg/types"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
Expand All @@ -31,16 +30,14 @@ func main() {
},
)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
exit.LogExit(err)
}

cmd.Version = version

err = cmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
exit.LogExit(err)
}
}

Expand Down
10 changes: 9 additions & 1 deletion docs/db2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `;`):
Comment thread
al-conductorone marked this conversation as resolved.

```
HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=db2inst1;PWD=pass123;PROTOCOL=TCPIP
```

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.

## Troubleshooting

**`'sqlcli1.h' file not found`** — clidriver missing or `DB2HOME` wrong. Check that
Expand Down
8 changes: 8 additions & 0 deletions pkg/bsql/offline_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"net/url"
"strings"

"github.com/conductorone/baton-sql/pkg/database/db2"
)

// OfflineValidate performs YAML-level structural checks without opening a DB or
Expand Down Expand Up @@ -102,6 +104,12 @@ func resolveConnectScheme(c *DatabaseConfig) (string, error) {
if dsn == "" {
return "", errors.New("connect: scheme or dsn is required")
}
// A native DB2 DSN (HOSTNAME=...;DATABASE=...) carries no scheme prefix. Classify it
// via the shared detector so this check matches pkg/database's routing and does not
// misread a "://" inside a value as a scheme.
if db2.IsNativeDSN(dsn) {
return "db2", nil
}
// Placeholders like postgres://${HOST}/db — peel scheme before parse when possible.
if idx := strings.Index(dsn, "://"); idx > 0 {
return strings.ToLower(dsn[:idx]), nil
Expand Down
21 changes: 21 additions & 0 deletions pkg/bsql/offline_validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ func TestRejectNonV1_NonPostgresScheme(t *testing.T) {
require.Contains(t, strings.ToLower(err.Error()), "postgres")
}

func TestRejectNonV1_NativeDB2DSNRejectedAsDB2(t *testing.T) {
// A native DB2 DSN carries no scheme prefix; it must be classified as "db2" (via the
// shared detector), so v1 rejects it with the scheme message rather than the confusing
// "scheme missing from dsn". Also guards against a "://" inside a value misclassifying it.
for _, dsn := range []string{
"HOSTNAME=localhost;PORT=50000;DATABASE=TESTDB;UID=u;PWD=p;PROTOCOL=TCPIP",
"HOSTNAME=localhost;DATABASE=TESTDB;PWD=my://secret",
} {
cfg, err := Parse([]byte(minimalPostgresYAML()))
require.NoError(t, err)
cfg.Connect.Scheme = ""
cfg.Connect.DSN = dsn
s, err := resolveConnectScheme(&cfg.Connect)
require.NoError(t, err)
require.Equal(t, "db2", s)
err = RejectNonV1ProductFeatures(cfg)
require.Error(t, err)
require.Contains(t, err.Error(), "db2")
}
}

func TestRejectNonV1_PostgresqlAliasRejected(t *testing.T) {
cfg, err := Parse([]byte(minimalPostgresYAML()))
require.NoError(t, err)
Expand Down
3 changes: 3 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ func (c *Connector) Validate(ctx context.Context) (annotations.Annotations, erro

for name, db := range c.dbs {
if err := db.PingContext(ctx); err != nil {
if authErr := database.AuthError(err); authErr != nil {
return nil, authErr
}
Comment on lines +100 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: returning authErr directly drops both the database name and the driver's original error, so with a multi-database config an operator sees only "database authentication failed" with no indication of which database rejected the credentials, and errors.Is/As on the driver error no longer works upstream. status.FromError resolves wrapped statuses via errors.As and preserves the code, so wrapping keeps the Unauthenticated exit behavior:

Suggested change
if authErr := database.AuthError(err); authErr != nil {
return nil, authErr
}
if authErr := database.AuthError(err); authErr != nil {
return nil, fmt.Errorf("database %q ping failed: %w", name, authErr)
}

return nil, fmt.Errorf("database %q ping failed: %w", name, err)
}
}
Expand Down
35 changes: 35 additions & 0 deletions pkg/database/autherror.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package database

import (
"errors"
"strings"

"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. 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.
func AuthError(err error) error {
if err == nil {
return nil
}

var sqlState interface{ SQLState() string }
if errors.As(err, &sqlState) && strings.HasPrefix(sqlState.SQLState(), "28") {
Comment on lines +24 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: among this repo's vendored drivers, only pgx implements SQLState() string (vendor/github.com/jackc/pgx/v5/pgconn/errors.go:58) — so this branch effectively covers Postgres only. Vertica exposes it as a struct field (VError.SQLState, vendor/github.com/vertica/vertica-sql-go/errors.go:46), go_ibm_db puts SQLSTATE in Error.Diag[].State, and go-mssqldb only has SQLErrorNumber() (18456 = login failed); Oracle and HANA likewise. errors.As won't match any of them, so their bad-credential failures still surface as Unknown — including DB2, the engine this PR targets. Worth either adding per-driver branches or dropping "Vertica" from the doc comment so it doesn't read as covered. (Also, MySQL 1698/1044 are access-denied variants that 1045 alone misses.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthError currently recognizes only drivers implementing SQLState() string plus MySQL 1045, so it misses the DB2 engine this PR targets: go_ibm_db exposes SQLSTATE through *go_ibm_db.Error.Diag[].State, not a method. It also returns status.Error, which discards the original driver error and prevents downstream errors.As. Add per-driver extraction (including DB2 class 28) and wrap the original with uhttp.WrapErrors(codes.Unauthenticated, ..., err); add a DB2 diagnostic test. Driver shape at this HEAD:

type DiagRecord struct {
State string
NativeError int
Message string
}
func (r *DiagRecord) String() string {
return fmt.Sprintf("{%s} %s", r.State, r.Message)
}
type Error struct {
APIName string
Diag []DiagRecord
}
func (e *Error) Error() string {
trc.Trace1("error.go: Error() - ENTRY")

return status.Error(codes.Unauthenticated, "database authentication failed")
}

var myErr *mysql.MySQLError
if errors.As(err, &myErr) && myErr.Number == mysqlAccessDenied {
return status.Error(codes.Unauthenticated, "database authentication failed")
}

return nil
}
44 changes: 44 additions & 0 deletions pkg/database/autherror_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package database

import (
"errors"
"fmt"
"testing"

"github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func TestAuthError(t *testing.T) {
tests := []struct {
name string
err error
want codes.Code // codes.OK means expect nil
}{
{"nil", nil, codes.OK},
{"postgres invalid_password 28P01", &pgconn.PgError{Code: "28P01"}, codes.Unauthenticated},
{"postgres invalid_authorization 28000", &pgconn.PgError{Code: "28000"}, codes.Unauthenticated},
{"postgres non-auth relation missing 42P01", &pgconn.PgError{Code: "42P01"}, codes.OK},
{"postgres auth error wrapped", fmt.Errorf("ping: %w", &pgconn.PgError{Code: "28P01"}), codes.Unauthenticated},
{"mysql access denied 1045", &mysql.MySQLError{Number: 1045}, codes.Unauthenticated},
{"mysql other 1146", &mysql.MySQLError{Number: 1146}, codes.OK},
{"plain error", errors.New("boom"), codes.OK},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AuthError(tt.err)
if tt.want == codes.OK {
if got != nil {
t.Fatalf("want nil, got %v", got)
}
return
}
if status.Code(got) != tt.want {
t.Fatalf("want %v, got %v", tt.want, status.Code(got))
}
})
}
}
72 changes: 72 additions & 0 deletions pkg/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,26 @@ 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 carries any structured connect
// field that a native DB2 DSN would make redundant. A native DSN is self-contained;
// combining it with these (or a per-database override) silently drops them, so the
// caller rejects the combination. Scheme is excluded: "db2" alongside a native DSN
// is a supported, explicit 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) {
Expand Down Expand Up @@ -404,6 +417,34 @@ 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 an opaque ODBC keyword=value string, not a URL. Routing it
// through buildConnectionURL corrupts it (url.Parse/.String mangles the opaque form),
// so hand it to the driver verbatim. See docs/db2.md.
nativeDSN, _, isNativeDB2, err := nativeDB2DSN(opts)
if err != nil {
return nil, Unknown, err
}
if isNativeDB2 {
// A native DSN already carries host, port, credentials, params and the target
// database. Structured fields or a per-database override (set directly, or by
// ConnectMany for databases.static / discovery_query) would be silently dropped
// on the verbatim path, so reject the combination instead of connecting to the
// wrong database. See docs/db2.md.
if hasStructuredConnectFields(opts) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the mutual-exclusion check fires per-Connect, so the databases.discovery_query path (pkg/connector/connector.go:201) opens a real admin connection and executes the discovery query before ConnectMany rejects the native-DSN + databases combination. databases.static fails fast, but discovery does a full round trip first. Consider validating "native DSN + connect.databases/connect.database" once in openDatabases (or config validation) so the error surfaces before any connection is opened. (Confidence: high on the behavior, low severity.)

return nil, Unknown, errors.New(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this is a pure user-config error, but a bare errors.New carries no gRPC status. With the exit.LogExit change in this same PR (cmd/baton-sql/main.go:33), exitCode falls through to codes.Unknown and the process exits 2 rather than 3 (InvalidArgument). Wrapping with status.Error(codes.InvalidArgument, ...) would make the new exit-code mapping actually distinguish misconfiguration from an internal failure. (Confidence: high.)

"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
Expand Down Expand Up @@ -468,6 +509,37 @@ func Connect(ctx context.Context, opts ConnectOptions) (*sql.DB, DbEngine, error
}
}

// nativeDB2DSN reports whether opts carries a native DB2 DSN: an opaque ODBC
// keyword=value string (e.g. "HOSTNAME=...;DATABASE=...") rather than a db2:// URL.
// When it does, the env-expanded string (for verbatim handoff to the driver) and its
// DATABASE value are returned. The scheme, when set, must be db2; a URL-shaped DSN or
// foreign scheme is left to the normal URL path. Detection is db2.ParseNativeDSN, shared
// with convertToDB2DSN's passthrough, so one pass yields both facts without re-splitting.
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 != "db2" {
return "", "", false, nil
}

dsn, err := expandValue(opts.DSN, lookup)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: expandValue is a raw ${VAR} substitution with no ODBC quoting, and the result is handed to the driver verbatim — so a placeholder value containing ;, =, or } changes the DSN's structure rather than being treated as data. HOSTNAME=${H};DATABASE=PROD with H="x;DATABASE=DEV" silently connects to DEV, and a password containing ; breaks the connection with a confusing driver error. The db2:// path is safe here because quoteDB2Value brace-quotes such values; the native path has no equivalent. Worth documenting in docs/db2.md that placeholder values with ;/=/spaces must be brace-quoted in the DSN (PWD={${DB_PASS}}).

if err != nil {
return "", "", false, err
}
database, native := db2.ParseNativeDSN(dsn)
if !native {
return "", "", false, nil
}
return dsn, database, true, nil
}

func buildConnectionURL(opts ConnectOptions) (*url.URL, error) {
var (
parsedUrl *url.URL
Expand Down
Loading