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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,29 @@ jobs:
working-directory: explain
run: pnpm build

ui-extension:
name: Connection-modal UI extension
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "22.13"

- name: Install dependencies
working-directory: ui
run: npm ci

- name: Typecheck
working-directory: ui
run: npm run typecheck

- name: Build
working-directory: ui
run: npm run build

validate-manifest:
name: Validate .tabularium manifest
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ jobs:
shell: bash
working-directory: ui
run: |
npm install --no-audit --no-fund
npm ci --no-audit --no-fund
npm run build

- name: Build EXPLAIN parser
Expand Down
7 changes: 7 additions & 0 deletions .tabularium
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@
"supports_ssl": true,
"explain": true
},
"ui_extensions": [
{
"slot": "connection-modal.extra_fields",
"module": "ui/dist/index.js",
"driver": "sqlserver"
}
],
"type_mappings": {
"TIMESTAMP": "DATETIME2",
"BOOLEAN": "BIT",
Expand Down
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ infer = "0.22"
mssql-tiberius-bridge = "=0.1.0-preview.5"
# Direct dependency on the underlying protocol crate (lib name `mssql_tds`):
# the result-set traits used through `Client::inner_mut()` are not re-exported
# by the bridge. It is pinned in lockstep with the bridge; default integrated
# authentication is disabled because this plugin supports SQL auth only.
mssql-tds-preview = { version = "=0.1.0-preview.1", default-features = false }
# by the bridge. Pinned in lockstep with the bridge; default features
# (sspi/gssapi) stay on for Windows/Kerberos auth.
mssql-tds-preview = "=0.1.0-preview.1"
once_cell = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ This plugin enables Tabularis to connect to SQL Server instances, providing sche
| `host` | `localhost` | Yes unless using `connection_string` | SQL Server hostname or IP address |
| `port` | `1433` | No | TDS port |
| `database` | — | Yes unless using `connection_string` | Database the pool connects to |
| `username` | `sa` | Yes unless using `connection_string` | SQL-authenticated login |
| `username` | `sa` | Yes, unless integrated authentication is enabled or `connection_string` is used | SQL-authenticated login |
| `password` | — | If required by the server | Login password; redacted from connection errors |
| `ssl_mode` | `prefer` | No | `disable`, `prefer`, `require`, or `verify-full` |
| `ssl_ca` | — | No | Rejected; strict TLS uses the system trust store |
Expand All @@ -104,6 +104,25 @@ braces preserve semicolons inside values:
Server=tcp:localhost,1433;Database=master;User Id=sa;Password={p;assword};Encrypt=true;TrustServerCertificate=true;
```

### Windows/Kerberos integrated authentication

The connection modal's "Use Windows Authentication" checkbox is a
[UI extension](https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md#3b-ui-extensions)
this plugin contributes to the host's `connection-modal.extra_fields` slot
(`ui/`) — there is no dedicated connection field for it. Checking it writes
`extra.integrated_auth = "true"` (the host's generic, plugin-opaque field map)
and, on a host implementing [TabularisDB/tabularis#780](https://github.com/TabularisDB/tabularis/pull/780),
hides the username/password inputs. The same flag can be set directly via
`Integrated Security=True` / `Trusted_Connection=True` in `connection_string`
on any host, with or without the UI extension mechanism; either source
rejects a combined username or password.

It uses SSPI on Windows (no extra setup) and GSSAPI on Linux/macOS, loaded at
runtime via `dlopen`. The binary builds and starts without it, but connecting
fails at runtime if `libgssapi_krb5` (package `libgssapi-krb5-2` on
Debian/Ubuntu, `krb5-libs` on RHEL/Alpine) is missing, or without a valid
Kerberos ticket (`kinit`) and `/etc/krb5.conf`.

A connection string may be combined with discrete fields. Values explicitly
present in the string are authoritative, while discrete fields fill only
fields the string omits. Repeating the same value is allowed; contradictory
Expand Down Expand Up @@ -339,7 +358,7 @@ remaining pools.

## Known Limitations

- SQL authentication only; Azure AD and Windows Integrated Authentication are follow-up work.
- SQL authentication and Windows/Kerberos integrated authentication (`integrated_auth`) are supported; Azure AD authentication is follow-up work.
- Primary-key membership changes are disabled: the single-column alteration API cannot safely preserve composite PKs and referencing foreign keys.
- Custom CA files are rejected explicitly; strict verification uses the system trust store.
- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, and `refresh_materialized_view` deliberately return `-32601` rather than pretending the features are equivalent.
Expand Down
238 changes: 168 additions & 70 deletions src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct ParsedConnectionString {
ssl_key: Option<String>,
encrypt: Option<EncryptSetting>,
trust_server_certificate: Option<bool>,
integrated_auth: Option<bool>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -42,76 +43,97 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result<Connection
resolved.ssl_mode = non_empty(resolved.ssl_mode.take())
.map(|mode| normalize_ssl_mode(&mode))
.transpose()?;
if let Some(value) = resolved.extra.get("integrated_auth") {
if parse_bool("integrated_auth", value)? {
resolved.integrated_auth = true;
}
}

let Some(connection_string) = params
let connection_string = params
.connection_string
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(resolved);
};
.filter(|value| !value.is_empty());

if let Some(connection_string) = connection_string {
let parsed = ParsedConnectionString::parse(connection_string)?;
reconcile_string(
"host",
&mut resolved.host,
parsed.host,
|left, right| left.eq_ignore_ascii_case(right),
false,
)?;
reconcile_value("port", &mut resolved.port, parsed.port)?;
reconcile_string(
"username",
&mut resolved.username,
parsed.username,
str::eq,
false,
)?;
reconcile_string(
"password",
&mut resolved.password,
parsed.password,
str::eq,
true,
)?;

if let Some(database) = parsed.database {
let discrete = resolved.database.primary().trim();
if !discrete.is_empty() && discrete != database {
return Err(contradiction("database", discrete, &database, false));
}
resolved.database = DatabaseSelection::Single(database);
}

let parsed = ParsedConnectionString::parse(connection_string)?;
reconcile_string(
"host",
&mut resolved.host,
parsed.host,
|left, right| left.eq_ignore_ascii_case(right),
false,
)?;
reconcile_value("port", &mut resolved.port, parsed.port)?;
reconcile_string(
"username",
&mut resolved.username,
parsed.username,
str::eq,
false,
)?;
reconcile_string(
"password",
&mut resolved.password,
parsed.password,
str::eq,
true,
)?;

if let Some(database) = parsed.database {
let discrete = resolved.database.primary().trim();
if !discrete.is_empty() && discrete != database {
return Err(contradiction("database", discrete, &database, false));
reconcile_string(
"ssl_mode",
&mut resolved.ssl_mode,
parsed.ssl_mode,
str::eq,
false,
)?;
reconcile_string(
"ssl_ca",
&mut resolved.ssl_ca,
parsed.ssl_ca,
str::eq,
false,
)?;
reconcile_string(
"ssl_cert",
&mut resolved.ssl_cert,
parsed.ssl_cert,
str::eq,
false,
)?;
reconcile_string(
"ssl_key",
&mut resolved.ssl_key,
parsed.ssl_key,
str::eq,
false,
)?;

if let Some(integrated_auth) = parsed.integrated_auth {
if resolved.integrated_auth && !integrated_auth {
return Err(contradiction("integrated_auth", "true", "false", false));
}
resolved.integrated_auth = integrated_auth;
}
resolved.database = DatabaseSelection::Single(database);
}

reconcile_string(
"ssl_mode",
&mut resolved.ssl_mode,
parsed.ssl_mode,
str::eq,
false,
)?;
reconcile_string(
"ssl_ca",
&mut resolved.ssl_ca,
parsed.ssl_ca,
str::eq,
false,
)?;
reconcile_string(
"ssl_cert",
&mut resolved.ssl_cert,
parsed.ssl_cert,
str::eq,
false,
)?;
reconcile_string(
"ssl_key",
&mut resolved.ssl_key,
parsed.ssl_key,
str::eq,
false,
)?;
}

if resolved.integrated_auth
&& (non_empty(resolved.username.clone()).is_some()
|| non_empty(resolved.password.clone()).is_some())
{
return Err(
"SQL Server integrated authentication cannot be combined with a username or password"
.into(),
);
}

Ok(resolved)
}
Expand Down Expand Up @@ -255,12 +277,8 @@ impl ParsedConnectionString {
}
"sslkey" | "clientkey" => set_string(&mut self.ssl_key, value, "ssl_key")?,
"integratedsecurity" | "trustedconnection" => {
if parse_bool(key, &value)? {
return Err(
"SQL Server Integrated Authentication is not supported; use User Id and Password"
.into(),
);
}
let integrated = parse_bool(key, &value)?;
set_value(&mut self.integrated_auth, integrated, "integrated_auth")?;
}
"authentication" => {
if !value.eq_ignore_ascii_case("SqlPassword")
Expand All @@ -278,6 +296,7 @@ impl ParsedConnectionString {
| "connecttimeout"
| "connectiontimeout"
| "timeout"
| "commandtimeout"
| "multipleactiveresultsets"
| "marsconnection"
| "persistsecurityinfo"
Expand Down Expand Up @@ -752,6 +771,85 @@ mod tests {
assert_eq!(resolved.ssl_mode.as_deref(), Some("require"));
}

#[test]
fn integrated_security_sets_integrated_auth_flag() {
let resolved = resolve_connection_params(&params(
"Data Source=prod-db3.corp.isepankur.ee;Integrated Security=True;Persist Security Info=False;Pooling=False;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=True;Application Name=\"SQL Server Management Studio\";Command Timeout=0",
))
.unwrap();

assert_eq!(resolved.host.as_deref(), Some("prod-db3.corp.isepankur.ee"));
assert!(resolved.integrated_auth);
assert_eq!(resolved.ssl_mode.as_deref(), Some("require"));
}

#[test]
fn trusted_connection_alias_also_sets_integrated_auth() {
let resolved =
resolve_connection_params(&params("Server=localhost;Trusted_Connection=Yes")).unwrap();
assert!(resolved.integrated_auth);
}

#[test]
fn integrated_auth_rejects_username_and_password() {
for connection_string in [
"Server=localhost;Integrated Security=True;User Id=sa",
"Server=localhost;Integrated Security=True;Password=secret",
] {
let error = resolve_connection_params(&params(connection_string)).unwrap_err();
assert!(error.contains("integrated authentication"), "{error}");
}
}

#[test]
fn extra_field_sets_integrated_auth_without_a_connection_string() {
let mut input = ConnectionParams {
host: Some("localhost".into()),
..Default::default()
};
input.extra.insert("integrated_auth".into(), "true".into());

let resolved = resolve_connection_params(&input).unwrap();
assert!(resolved.integrated_auth);
}

#[test]
fn extra_field_integrated_auth_also_rejects_username_and_password() {
let mut input = ConnectionParams {
host: Some("localhost".into()),
username: Some("sa".into()),
..Default::default()
};
input.extra.insert("integrated_auth".into(), "true".into());

let error = resolve_connection_params(&input).unwrap_err();
assert!(error.contains("integrated authentication"), "{error}");
}

#[test]
fn extra_field_and_connection_string_agreeing_on_integrated_auth_is_accepted() {
let mut input = ConnectionParams {
connection_string: Some("Server=localhost;Integrated Security=True".into()),
..Default::default()
};
input.extra.insert("integrated_auth".into(), "true".into());

let resolved = resolve_connection_params(&input).unwrap();
assert!(resolved.integrated_auth);
}

#[test]
fn extra_field_and_connection_string_disagreeing_on_integrated_auth_is_rejected() {
let mut input = ConnectionParams {
connection_string: Some("Server=localhost;Integrated Security=False".into()),
..Default::default()
};
input.extra.insert("integrated_auth".into(), "true".into());

let error = resolve_connection_params(&input).unwrap_err();
assert!(error.contains("integrated_auth"), "{error}");
}

#[test]
fn contradictory_values_name_both_sources() {
let mut input = params("Server=from-string;Database=app");
Expand Down
Loading