diff --git a/baton_capabilities.json b/baton_capabilities.json
new file mode 100644
index 00000000..e261c031
--- /dev/null
+++ b/baton_capabilities.json
@@ -0,0 +1,78 @@
+{
+ "@type": "type.googleapis.com/c1.connector.v2.ConnectorCapabilities",
+ "resourceTypeCapabilities": [
+ {
+ "resourceType": {
+ "id": "database",
+ "displayName": "Database"
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_PROVISION"
+ ],
+ "permissions": {}
+ },
+ {
+ "resourceType": {
+ "id": "routine",
+ "displayName": "Routine"
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_PROVISION"
+ ],
+ "permissions": {}
+ },
+ {
+ "resourceType": {
+ "id": "server",
+ "displayName": "Server"
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_PROVISION"
+ ],
+ "permissions": {}
+ },
+ {
+ "resourceType": {
+ "id": "table",
+ "displayName": "Table"
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_PROVISION"
+ ],
+ "permissions": {}
+ },
+ {
+ "resourceType": {
+ "id": "user",
+ "displayName": "User",
+ "traits": [
+ "TRAIT_USER"
+ ]
+ },
+ "capabilities": [
+ "CAPABILITY_SYNC",
+ "CAPABILITY_ACCOUNT_PROVISIONING",
+ "CAPABILITY_RESOURCE_DELETE"
+ ],
+ "permissions": {}
+ }
+ ],
+ "connectorCapabilities": [
+ "CAPABILITY_PROVISION",
+ "CAPABILITY_SYNC",
+ "CAPABILITY_ACCOUNT_PROVISIONING",
+ "CAPABILITY_RESOURCE_DELETE"
+ ],
+ "credentialDetails": {
+ "capabilityAccountProvisioning": {
+ "supportedCredentialOptions": [
+ "CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD"
+ ],
+ "preferredCredentialOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD"
+ }
+ }
+}
\ No newline at end of file
diff --git a/config_schema.json b/config_schema.json
new file mode 100644
index 00000000..f01944b4
--- /dev/null
+++ b/config_schema.json
@@ -0,0 +1,126 @@
+{
+ "fields": [
+ {
+ "name": "log-level",
+ "description": "The log level: debug, info, warn, error",
+ "isOps": true,
+ "stringField": {
+ "defaultValue": "info"
+ }
+ },
+ {
+ "name": "log-level-debug-expires-at",
+ "description": "The timestamp indicating when debug-level logging should expire",
+ "isOps": true,
+ "stringField": {}
+ },
+ {
+ "name": "session-store-maximum-size",
+ "description": "The maximum size of the local in-memory session store cache in bytes.",
+ "isOps": true,
+ "intField": {
+ "defaultValue": "15728640"
+ }
+ },
+ {
+ "name": "otel-collector-endpoint",
+ "description": "The endpoint of the OpenTelemetry collector to send observability data to (used for both tracing and logging if specific endpoints are not provided)",
+ "isOps": true,
+ "stringField": {}
+ },
+ {
+ "name": "otel-collector-endpoint-tls-cert-path",
+ "description": "Path to a file containing a PEM-encoded certificate to use as a CA for TLS connections to the OpenTelemetry collector",
+ "isOps": true,
+ "stringField": {}
+ },
+ {
+ "name": "otel-collector-endpoint-tls-cert",
+ "description": "A PEM-encoded certificate to use as a CA for TLS connections to the OpenTelemetry collector",
+ "isOps": true,
+ "stringField": {}
+ },
+ {
+ "name": "otel-collector-endpoint-tls-insecure",
+ "description": "Allow insecure connections to the OpenTelemetry collector",
+ "isOps": true,
+ "boolField": {}
+ },
+ {
+ "name": "otel-tracing-disabled",
+ "description": "Disable OpenTelemetry tracing",
+ "isOps": true,
+ "boolField": {}
+ },
+ {
+ "name": "otel-logging-disabled",
+ "description": "Disable OpenTelemetry logging",
+ "isOps": true,
+ "boolField": {}
+ },
+ {
+ "name": "health-check",
+ "description": "Enable the HTTP health check endpoint",
+ "isOps": true,
+ "boolField": {}
+ },
+ {
+ "name": "health-check-port",
+ "description": "Port for the HTTP health check endpoint",
+ "isOps": true,
+ "intField": {
+ "defaultValue": "8081"
+ }
+ },
+ {
+ "name": "health-check-bind-address",
+ "description": "Bind address for health check server (127.0.0.1 for localhost-only)",
+ "isOps": true,
+ "stringField": {
+ "defaultValue": "127.0.0.1"
+ }
+ },
+ {
+ "name": "http-timeout-seconds",
+ "description": "HTTP client timeout in seconds (max 1800)",
+ "isOps": true,
+ "intField": {
+ "defaultValue": "300",
+ "rules": {
+ "lte": "1800",
+ "gte": "1"
+ }
+ }
+ },
+ {
+ "name": "connection-string",
+ "description": "The connection string for connecting to MySQL ($BATON_CONNECTION_STRING)",
+ "isRequired": true,
+ "isSecret": true,
+ "stringField": {
+ "rules": {
+ "isRequired": true
+ }
+ }
+ },
+ {
+ "name": "skip-database",
+ "description": "Skip syncing privileges from these databases ($BATON_SKIP_DATABASE)",
+ "stringSliceField": {
+ "rules": {}
+ }
+ },
+ {
+ "name": "expand-columns",
+ "description": "Provide a table like db.table to expand the column privileges into their own entitlements. $(BATON_EXPAND_COLUMNS)",
+ "stringSliceField": {
+ "rules": {}
+ }
+ },
+ {
+ "name": "collapse-users",
+ "description": "Combine user@host pairs into a single user@[hosts...] identity $(BATON_COLLAPSE_USERS)",
+ "boolField": {}
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/connector.mdx b/docs/connector.mdx
new file mode 100644
index 00000000..2894cc11
--- /dev/null
+++ b/docs/connector.mdx
@@ -0,0 +1,209 @@
+---
+title: "Set up a MySQL connector"
+og:title: "Set up a MySQL connector"
+description: "ConductorOne provides identity governance for MySQL. Integrate your MySQL instance with ConductorOne to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision access."
+og:description: "ConductorOne provides identity governance for MySQL. Integrate your MySQL instance with ConductorOne to run user access reviews (UARs), enable just-in-time access requests, and automatically provision and deprovision access."
+sidebarTitle: "MySQL"
+---
+
+{/* AUTO-GENERATED:START - capabilities
+ Generated from baton_capabilities.json. Do not edit manually. */}
+
+## Capabilities
+
+The MySQL connector syncs the following resources:
+
+| Resource | Sync | Provision |
+| :--- | :--- | :--- |
+| Databases | | Grant, Revoke |
+| Routines | | Grant, Revoke |
+| Servers | | Grant, Revoke |
+| Tables | | Grant, Revoke |
+| Users | | Create, Delete |
+
+{/* AUTO-GENERATED:END - capabilities */}
+
+**Notes:**
+
+- Roles are synced and their privilege assignments can be granted and revoked on **MySQL 8+** only. The role resource type is enabled automatically when a MySQL 8 server is detected.
+- Columns are synced as sub-resources of tables when the `--expand-columns` flag is configured.
+
+## Gather MySQL credentials
+
+
+ The ConductorOne connector requires a dedicated MySQL user with `SELECT`
+ privileges on the `mysql` system database tables that contain privilege
+ information. Creating a least-privilege user is strongly recommended over
+ using a root account.
+
+
+### Create a MySQL connector user
+
+
+
+ Log in to your MySQL server using an account with `GRANT` privileges (for example, `root`).
+
+
+ Create a dedicated user for the connector:
+
+ ```sql
+ CREATE USER conductorone IDENTIFIED BY 'secure-password';
+ ```
+
+
+ Grant the minimum required privileges. Choose the block that matches your MySQL version.
+
+ **MySQL 5.7:**
+
+ ```sql
+ GRANT SELECT (Host, User, Db, Select_priv, Insert_priv, Update_priv, Delete_priv,
+ Create_priv, Drop_priv, Grant_priv, References_priv, Index_priv, Alter_priv,
+ Create_tmp_table_priv, Lock_tables_priv, Execute_priv, Create_view_priv,
+ Show_view_priv, Create_routine_priv, Alter_routine_priv, Event_priv,
+ Trigger_priv) ON mysql.db TO conductorone;
+ GRANT SELECT (Host, User, Db, Table_priv, Table_name) ON mysql.tables_priv TO conductorone;
+ GRANT SELECT (Host, User, Db, Column_name, Column_priv, Table_name) ON mysql.columns_priv TO conductorone;
+ GRANT SELECT (Host, User, Select_priv, Insert_priv, Update_priv, Delete_priv, Create_priv,
+ Drop_priv, Reload_priv, Shutdown_priv, Process_priv, References_priv, Index_priv,
+ Alter_priv, Show_db_priv, Super_priv, Create_tmp_table_priv, Lock_tables_priv,
+ Execute_priv, Repl_slave_priv, Repl_client_priv, Create_view_priv, Show_view_priv,
+ Create_routine_priv, Alter_routine_priv, Create_user_priv, Event_priv, Trigger_priv,
+ Create_tablespace_priv, File_priv, Grant_priv, authentication_string) ON mysql.user TO conductorone;
+ ```
+
+ **MySQL 8+:**
+
+ ```sql
+ GRANT SELECT (USER, HOST, PRIV, WITH_GRANT_OPTION) ON mysql.global_grants TO conductorone;
+ GRANT SELECT (Host, User, Db, Select_priv, Insert_priv, Update_priv, Delete_priv,
+ Create_priv, Drop_priv, Grant_priv, References_priv, Index_priv, Alter_priv,
+ Create_tmp_table_priv, Lock_tables_priv, Execute_priv, Create_view_priv,
+ Show_view_priv, Create_routine_priv, Alter_routine_priv, Event_priv,
+ Trigger_priv) ON mysql.db TO conductorone;
+ GRANT SELECT (Host, User, Db, Table_priv, Table_name) ON mysql.tables_priv TO conductorone;
+ GRANT SELECT (Host, User, Db, Column_name, Column_priv, Table_name) ON mysql.columns_priv TO conductorone;
+ GRANT SELECT (Host, User, Select_priv, Insert_priv, Update_priv, Delete_priv, Create_priv,
+ Drop_priv, Reload_priv, Shutdown_priv, Process_priv, References_priv, Index_priv,
+ Alter_priv, Show_db_priv, Super_priv, Create_tmp_table_priv, Lock_tables_priv,
+ Execute_priv, Repl_slave_priv, Repl_client_priv, Create_view_priv, Show_view_priv,
+ Create_routine_priv, Alter_routine_priv, Create_user_priv, Event_priv, Trigger_priv,
+ Create_tablespace_priv, Create_role_priv, Drop_role_priv, File_priv, Grant_priv,
+ authentication_string) ON mysql.user TO conductorone;
+ GRANT SELECT (FROM_HOST, FROM_USER, TO_HOST, TO_USER, WITH_ADMIN_OPTION) ON mysql.role_edges TO conductorone;
+ ```
+
+
+ Grant `SELECT` on all databases so the connector can introspect schemas:
+
+ ```sql
+ GRANT SELECT ON *.* TO conductorone;
+ ```
+
+
+ Note the connection string in DSN format — you will need it when configuring the connector:
+
+ ```
+ conductorone:secure-password@tcp(your-mysql-host:3306)/
+ ```
+
+
+
+That's it! You now have the credentials needed to configure the connector.
+
+## Configure the MySQL connector
+
+
+ You must have the **Connector Administrator** or **Super Administrator** role in ConductorOne to add a connector. MySQL is a self-hosted connector — you run the Baton binary in your own infrastructure. You will need:
+
+ - MySQL connection string (`user:password@tcp(host:port)/`)
+ - A C1 Client ID and Client Secret (obtained in Step 1 below)
+
+
+### Resources
+
+- Binary and packages: [https://dist.conductorone.com/ConductorOne/baton-mysql](https://dist.conductorone.com/ConductorOne/baton-mysql)
+- Source code: [https://github.com/conductorone/baton-mysql](https://github.com/conductorone/baton-mysql)
+
+### Step 1
+
+Add a Baton connector in ConductorOne and rotate credentials to get a C1 Client ID and Client Secret.
+
+
+
+ In ConductorOne, go to **Connectors** → **Add connector** → **Baton**.
+
+
+ Name the connector and set an owner, then save.
+
+
+ On the connector detail page, click **Rotate credentials** and copy the **Client ID** and **Client Secret**.
+
+
+
+### Step 2
+
+Deploy the connector using Kubernetes. Create the following manifests:
+
+{/* AUTO-GENERATED:START - config-params
+ Generated from config_schema.json. Do not edit manually. */}
+
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: baton-mysql-secret
+type: Opaque
+stringData:
+ BATON_CLIENT_ID: ""
+ BATON_CLIENT_SECRET: ""
+ BATON_CONNECTION_STRING: ""
+ # Optional:
+ # BATON_SKIP_DATABASE: "performance_schema,information_schema"
+ # BATON_EXPAND_COLUMNS: "mydb.mytable,otherdb.othertable"
+ # BATON_COLLAPSE_USERS: "true"
+```
+
+The supported environment variables are:
+
+- **BATON_CONNECTION_STRING** (required): MySQL connection string in DSN format, e.g. `user:password@tcp(host:3306)/`
+- **BATON_SKIP_DATABASE**: Comma-separated list of database names to exclude from the sync.
+- **BATON_EXPAND_COLUMNS**: Comma-separated list of tables (in `db.table` format) whose column-level privileges should be surfaced as individual entitlements.
+- **BATON_COLLAPSE_USERS**: When `true`, combines `user@host` pairs that share the same username into a single identity.
+
+{/* AUTO-GENERATED:END - config-params */}
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: baton-mysql
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: baton-mysql
+ template:
+ metadata:
+ labels:
+ app: baton-mysql
+ spec:
+ containers:
+ - name: baton-mysql
+ image: public.ecr.aws/conductorone/baton-mysql:latest
+ envFrom:
+ - secretRef:
+ name: baton-mysql-secret
+```
+
+### Step 3
+
+Apply the manifests and verify the connector appears as **Connected** in ConductorOne.
+
+```bash
+kubectl apply -f baton-mysql-secret.yaml
+kubectl apply -f baton-mysql-deployment.yaml
+```
+
+
+ Download the `baton-mysql` binary and packages from the [ConductorOne distribution center](https://dist.conductorone.com/ConductorOne/baton-mysql).
+
diff --git a/docs/docs-info.md b/docs/docs-info.md
new file mode 100644
index 00000000..17d8cb75
--- /dev/null
+++ b/docs/docs-info.md
@@ -0,0 +1,59 @@
+# Baton MySQL - Connector Documentation
+
+This document provides information needed to set up and use the connector.
+
+## Connector Capabilities
+
+### 1. What resources does the connector sync?
+
+| Resource | Description |
+|----------|-------------|
+| **Server** | The MySQL server instance itself; root of the resource hierarchy |
+| **Database** | Individual databases hosted on the server; scoped privilege assignments |
+| **Table** | Tables within databases; table-level privilege grants |
+| **Routine** | Stored procedures and functions; execute/alter privilege grants |
+| **User** | MySQL accounts (`user@host`); principal identities in the access graph |
+| **Role** | Named role objects (MySQL 8+ only); role-to-user assignment grants |
+| **Column** | Individual table columns (optional, requires `--expand-columns`); column-level privilege grants |
+
+### 2. Can the connector provision any resources? If so, which ones?
+
+Yes.
+
+| Resource | Grant | Revoke | Create | Delete |
+|----------|-------|--------|--------|--------|
+| **Database privileges** | ✅ Grants a privilege on a database to a user | ✅ Revokes a privilege on a database from a user | - | - |
+| **Table privileges** | ✅ Grants a privilege on a table to a user | ✅ Revokes a privilege on a table from a user | - | - |
+| **Routine privileges** | ✅ Grants a privilege on a routine to a user | ✅ Revokes a privilege on a routine from a user | - | - |
+| **Role assignments** | ✅ Assigns a role to a user (MySQL 8+ only) | ✅ Removes a role from a user (MySQL 8+ only) | - | - |
+| **User accounts** | - | - | ✅ Creates a new MySQL user with a generated password | ✅ Drops a MySQL user account |
+
+## Connector Credentials
+
+### 1. What credentials or information are needed to set up the connector?
+
+| Credential | Required | Description |
+|------------|----------|-------------|
+| **connection-string** | Yes | MySQL DSN in Go driver format: `user:password@tcp(host:port)/` |
+| **skip-database** | No | Comma-separated list of database names to exclude from sync |
+| **expand-columns** | No | Tables (`db.table`) whose column-level privileges are expanded into individual entitlements |
+| **collapse-users** | No | Combines `user@host` accounts with the same username into a single identity (default: false) |
+
+### 2. How are these credentials obtained?
+
+Create a dedicated MySQL user and grant it the minimum read privileges on `mysql` system tables. See `README.md` (Advanced Setup section) for the exact `GRANT` statements for MySQL 5.7 and MySQL 8+.
+
+The connection string format is: `username:password@tcp(hostname:3306)/`
+
+## Additional Notes
+
+### MySQL Version Requirements
+
+- **MySQL 5.7 and 8.x** are supported.
+- Role sync and role grant/revoke are available on **MySQL 8+** only. The connector detects the server version at runtime and enables role support automatically.
+
+### API Documentation Links
+
+- [MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/)
+- [MySQL 5.7 Reference Manual](https://dev.mysql.com/doc/refman/5.7/en/)
+- [Go MySQL Driver DSN format](https://github.com/go-sql-driver/mysql#dsn-data-source-name)
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 715cdee0..6dd3f865 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -47,12 +47,12 @@ func (t dbResourceID) Column() dbResourceID {
func (t dbResourceID) SQLString() (string, error) {
var sb strings.Builder
- _, err := sb.WriteString(fmt.Sprintf("`%s`", t.DatabaseName))
+ _, err := fmt.Fprintf(&sb, "`%s`", t.DatabaseName)
if err != nil {
return "", err
}
if t.ResourceName != "" && (t.ResourceTypeID == TableType || t.ResourceTypeID == ColumnType) {
- _, err = sb.WriteString(fmt.Sprintf(".`%s`", t.ResourceName))
+ _, err = fmt.Fprintf(&sb, ".`%s`", t.ResourceName)
if err != nil {
return "", err
}
@@ -63,15 +63,13 @@ func (t dbResourceID) SQLString() (string, error) {
func (t dbResourceID) String() string {
var sb strings.Builder
- sb.WriteString(fmt.Sprintf("%s:%s", t.ResourceTypeID, t.DatabaseName))
+ fmt.Fprintf(&sb, "%s:%s", t.ResourceTypeID, t.DatabaseName)
if t.ResourceName != "" {
- sb.WriteString(".")
- sb.WriteString(t.ResourceName)
+ fmt.Fprintf(&sb, ".%s", t.ResourceName)
if t.SubResourceName != "" {
- sb.WriteString(".")
- sb.WriteString(t.SubResourceName)
+ fmt.Fprintf(&sb, ".%s", t.SubResourceName)
}
}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index e6b43c42..845c8bc6 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -286,18 +286,18 @@ func Test_generateRandomGrants(t *testing.T) {
_, _ = grantStatements.WriteString(", ")
}
- _, _ = grantStatements.WriteString(fmt.Sprintf(`%s (`, p))
+ _, _ = fmt.Fprintf(&grantStatements, "%s (", p)
for jj, col := range g.resourceIDs {
if jj != 0 {
_, _ = grantStatements.WriteString(", ")
}
- _, _ = grantStatements.WriteString(fmt.Sprintf("`%s`", col.SubResourceName))
+ _, _ = fmt.Fprintf(&grantStatements, "`%s`", col.SubResourceName)
}
_, _ = grantStatements.WriteString(")")
}
resourceString, err := g.resourceIDs[0].SQLString()
require.NoError(t, err)
- _, _ = grantStatements.WriteString(fmt.Sprintf(" ON %s TO %s;", resourceString, accountID))
+ _, _ = fmt.Fprintf(&grantStatements, " ON %s TO %s;", resourceString, accountID)
_ = grantStatements.WriteByte('\n')
case TableType:
@@ -318,7 +318,7 @@ func Test_generateRandomGrants(t *testing.T) {
}
resourceString, err := g.resourceIDs[0].SQLString()
require.NoError(t, err)
- _, _ = grantStatements.WriteString(fmt.Sprintf(" ON %s TO %s;\n", resourceString, accountID))
+ _, _ = fmt.Fprintf(&grantStatements, " ON %s TO %s;\n", resourceString, accountID)
case DatabaseType:
privs := randomPrivs(r, dbPrivs(), privCount)
@@ -338,7 +338,7 @@ func Test_generateRandomGrants(t *testing.T) {
}
resourceString, err := g.resourceIDs[0].SQLString()
require.NoError(t, err)
- _, _ = grantStatements.WriteString(fmt.Sprintf(" ON %s.* TO %s;\n", resourceString, accountID))
+ _, _ = fmt.Fprintf(&grantStatements, " ON %s.* TO %s;\n", resourceString, accountID)
default:
require.NoError(t, fmt.Errorf("invalid resource type for grant item"))
}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 4bcffb4c..5ac36368 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -79,7 +79,7 @@ func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
newUserSyncer(c.client, c.skipDbs, c.expandCols, c.collapseUsers),
}
- if c.client.IsVersion8() {
+ if c.client != nil && c.client.IsVersion8() {
syncers = append(syncers, newRoleSyncer(c.client, c.skipDbs, c.expandCols))
}