Skip to content

fix: support MySQL usernames containing @ - #27

Open
OneWhoNests wants to merge 7 commits into
mainfrom
fix/username-with-at-sign
Open

fix: support MySQL usernames containing @#27
OneWhoNests wants to merge 7 commits into
mainfrom
fix/username-with-at-sign

Conversation

@OneWhoNests

Copy link
Copy Markdown

Summary

  • MySQL usernames can legally contain @ (e.g. accounts named after an email address like someone@orion.com). The connector built its user@host composite resource ID via fmt.Sprintf("%s@%s", user, host), then re-parsed it everywhere by splitting on every @ and requiring exactly 2 parts. For a username containing @, this produced ambiguous IDs (e.g. someone@orion.com@%) that failed the parts-count check, aborting sync with malformed principal ID during grant processing — even though role/entitlement listing had already succeeded.
  • Adds client.SplitUserHost, which splits on the last @ instead — MySQL host specifications (hostnames, IPs, netmasks, % wildcards) never contain @, so this unambiguously recovers (user, host) even when the username itself has one or more @ characters.
  • Applied at every place that previously did the naive split: principal-ID parsing for grants listing (pkg/connector/grants.go) and user deletion (pkg/connector/user.go), plus grant/revoke for databases, tables, columns, routines, servers, roles, and user create/drop (pkg/client/*.go).
  • Widened the validUserHost identifier-validation regex to permit @ (previously any @-containing username would still be rejected here even if parsing succeeded).

Test plan

  • go build ./...
  • go vet ./...
  • go test ./... -count=1 (existing suite + new pkg/client/helper_test.go covering SplitUserHost and escapeMySQLUserHost, including usernames with one or multiple embedded @, comma-separated collapsed hosts, and malformed-input error cases)

🤖 Generated with Claude Code

Comment thread pkg/client/columns.go Outdated
Comment thread pkg/client/helper.go Outdated
Comment thread pkg/connector/user.go Outdated
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: fix: support MySQL usernames containing @

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base a8fcc6016960.
Review mode: incremental since 8abb097
View review run

Review Summary

The new commit addresses all three prior findings: CreateAccount now rejects an empty username before provisioning MySQL's anonymous account (pkg/connector/user.go:113), sets UserType: client.UserType so GetID() yields a parseable user:name@host for the subsequent Delete (pkg/connector/user.go:137), and validUserHost now permits : and / for IPv6 and netmask hosts while still excluding ' and \ (pkg/client/helper.go:30) — verified safe, since every caller feeds it parts already split by SplitUserHost and interpolates them inside single quotes. The full PR diff was scanned for security and correctness; no blocking issues found. One gap remains: the IPv6 widening is not yet effective end-to-end for grant/revoke.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/client/helper.go:30 — IPv6 hosts are now accepted by escapeMySQLUserHost, but connector-side principal-ID parsing in server.go:77/91, database.go:85/100, column.go:80/105, table.go:91/112, routine.go:106/150, and role.go:96/118 still splits the resource ID on :, so user:root@::1 still fails grant/revoke. Pre-existing, but it means the stated IPv6 goal is only reached for Delete and grant listing.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/server.go`, `pkg/connector/database.go`, `pkg/connector/column.go`,
`pkg/connector/table.go`, `pkg/connector/routine.go`, `pkg/connector/role.go`:
- server.go around lines 77 and 91, database.go around lines 85 and 100, and
  column.go around line 80: these do `strings.Split(principal.Id.Resource, ":")[1]`
  to strip the resource-type prefix. For an IPv6-host principal such as
  `user:root@::1` this yields `"root@"`, which then fails `SplitUserHost` in the
  client with a misleading "invalid user@host format" error. Replace the split with
  `strings.TrimPrefix(principal.Id.Resource, principal.Id.ResourceType+":")` (and
  the `grant.Principal.Id` equivalents in the Revoke variants), matching what
  `pkg/connector/grants.go:25` and `pkg/connector/user.go:179` already do.
- table.go around lines 91 and 112, routine.go around lines 106 and 150, role.go
  around lines 96 and 118, and column.go around line 105: these split the principal
  ID on ":" and reject anything where the part count is not 2. An IPv6 host makes
  that count 4, so grant/revoke returns "invalid principal ID" before reaching the
  client. Replace the split-and-count with the same `strings.TrimPrefix` on the
  resource-type prefix, and let `client.SplitUserHost` inside the client report a
  genuinely malformed identifier.
- Also note server.go:78, database.go:86, and column.go:80 index element 1 without
  a length check; switching to TrimPrefix removes that latent index-out-of-range
  path as well.

@github-actions github-actions Bot left a comment

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.

Blocking issues found — see review comments.

@OneWhoNests

Copy link
Copy Markdown
Author

Manual verification against a live MySQL 8.0 container

Spun up mysql:8.0 in Docker and exercised both the read (sync) and write (provisioning) paths with a username containing @.

Setup

CREATE USER 'someone@orion.com'@'%' IDENTIFIED BY 'Passw0rd!';
GRANT SELECT, INSERT ON testdb.* TO 'someone@orion.com'@'%';
CREATE ROLE 'app_reader';
GRANT SELECT ON testdb.* TO 'app_reader';
GRANT 'app_reader' TO 'someone@orion.com'@'%';

Read path (the originally reported bug)

Ran a full baton-mysql sync (--log-level debug) against the container on this branch.

  • Sync completed with no malformed principal ID errors and no panics.
  • user:someone@orion.com@% synced as a resource:
    user:someone@orion.com@%  |  someone@orion.com@%  |  User  |  <server>
    
  • baton grants --resource-type database --resource database:testdb shows the grants correctly attributed to the full @-containing principal:
    grant:entitlement:select:database:testdb:user:someone@orion.com@%  |  SELECT testdb.*  |  someone@orion.com@%
    grant:entitlement:insert:database:testdb:user:someone@orion.com@%  |  INSERT testdb.*  |  someone@orion.com@%
    
  • Role membership grant also came through correctly:
    grant:entitlement:role_assignment:user:someone@orion.com@%:role:app_reader@%  |  someone@orion.com@% Role Member  |  app_reader@%
    
  • This matches MySQL's own SHOW GRANTS FOR 'someone@orion.com'@'%' output exactly.

Reverting the fix (checking out main) reproduces the originally reported failure: malformed principal ID during grant processing for this user.

Write path (provisioning)

Wrote a throwaway test exercising the pkg/client layer directly against the container (not part of this PR — scratch verification only):

  • CreateUser for integration.tester@example.com@% — succeeds
  • GrantDatabasePrivilege(testdb, ..., select) — succeeds
  • GetUser / ListDatabaseGrants correctly resolve (user, host) via the last-@ split and find the grant
  • RevokeDatabasePrivilege — succeeds, grant confirmed gone
  • DropUser — succeeds, user confirmed gone

All operations round-tripped correctly end-to-end for a username containing @.

Automated checks

go build ./...   # clean
go vet ./...     # clean
go test ./... -count=1   # all pass, including new pkg/client/helper_test.go

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@OneWhoNests
OneWhoNests dismissed github-actions[bot]’s stale review August 21, 2026 17:07

All three blocking findings from this review were fixed in commit 1bc5821 (column grant/revoke escaping, backslash removed from validUserHost, TrimPrefix-based ID parsing in Delete) and verified against a live MySQL container. The bot re-reviewed at 2026-08-21T17:06:22Z and reported "No blocking issues found." Dismissing this stale CHANGES_REQUESTED review since it only supports COMMENTED reviews, not approvals.

@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

CXH-2298

Comment thread pkg/connector/user.go Outdated

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Comment thread pkg/client/helper.go
Comment thread pkg/client/roles.go

@github-actions github-actions Bot left a comment

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.

Blocking issues found — see review comments.

Comment thread pkg/client/helper.go

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@OneWhoNests
OneWhoNests dismissed github-actions[bot]’s stale review August 21, 2026 21:42

All findings from this review round were fixed: idx<=0 -> idx<0 regression fix for the anonymous MySQL account (commit 8857601), escapeMySQLUserHost zero-or-more fix for the same account's empty username (commit c01af17), and the roles.go error-wrapping suggestion (commit 8857601). Verified against a live MySQL container including full Grant/Revoke/DropUser round-trip against a real ''@'localhost' account. The bot's own follow-up review at 2026-08-21T21:36:23Z reported "No blocking issues found." Dismissing this stale CHANGES_REQUESTED review since it only supports COMMENTED reviews, not approvals.

Comment thread pkg/client/helper.go Outdated

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

OneWhoNests and others added 6 commits August 21, 2026 17:55
MySQL usernames can legally contain "@" (e.g. an account named after an
email address), but the connector's user@host composite ID was built as
"user@host" and then reassembled everywhere by naively splitting on every
"@" and requiring exactly 2 parts. A username like "someone@orion.com"
produced an ID like "someone@orion.com@%", which failed to parse and
aborted sync during grant processing with "malformed principal ID".

Add client.SplitUserHost, which splits on the *last* "@" instead (MySQL
host specs never contain "@", so this is unambiguous), and use it at
every user@host parsing site: grant/revoke for databases, tables,
columns, routines, servers, roles, users, plus principal-ID parsing in
grants listing and user deletion. Also widen the user/host identifier
validation regex to allow "@".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GrantColumnPrivilege/RevokeColumnPrivilege now validate user/host via
  escapeMySQLUserHost, matching every other converted call site. They
  previously interpolated the raw split values straight into the GRANT/
  REVOKE statement, which was a SQL injection vector for a user/host
  containing a quote. Also wrap the SplitUserHost error with %w instead
  of dropping it.
- validUserHost no longer matches a literal backslash. Combined with the
  '%s'@'%s' quoting used throughout, a name ending in "\" could escape
  the closing quote under MySQL's default (non-NO_BACKSLASH_ESCAPES)
  sql_mode. Backslash was already allowed before this PR; this was a
  good moment to drop it while rewriting the character class.
- userSyncer.Delete now derives the composite ID via TrimPrefix on the
  resource type, matching grantsForUserOrRole, instead of
  strings.Split(...)[1], which panics if the ID has no ":" and silently
  truncates names containing ":".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rs.WithUserProfile and rs.WithStatus are deprecated (profile/status
moved from UserTrait to a Resource-level attribute). CI's lint check
flags these regardless of whether the resource-level mirroring happens,
which only applies when going through WithUserTrait/NewUserResource —
this connector builds *v2.Resource via a struct literal instead, so
switch to setting the resource-level fields directly via
rs.WithResourceProfile/rs.WithResourceStatus.

Verified no SA1019 findings remain repo-wide (golangci-lint), and that
the resulting resource has HasProfile()/HasStatus() populated
correctly via a standalone check against the real SDK types.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address review feedback: List() and parseIntoUserResource() previously
hand-built *v2.Resource via a struct literal, duplicating the same
profile-map/status boilerplate in two places and skipping the SDK's
NewUserResource/WithUserTrait helper entirely. Consolidated List() to
just call parseIntoUserResource() per user, and rewrote
parseIntoUserResource() to build through rs.NewUserResource with
WithParentResourceID/WithResourceProfile/WithResourceStatus.

Verified against a live MySQL container that resources, parent
linkage, display name, and the @-in-username grants still round-trip
identically after the refactor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SplitUserHost rejected idx <= 0, which also rejected the empty-name
case (idx == 0) -- but MySQL's anonymous account is a real, valid
entity of the form ''@'host'. The old strings.Split-based check
accepted it (split on "@" yields ["", host]), so this was a regression:
default MySQL/MariaDB installs ship an anonymous account, and
grantsForUserOrRole would now fail the entire sync on it. Only reject
when there's no "@" at all (idx < 0) or the host half is empty.

Also wrap the SplitUserHost error with %w in GrantRolePrivilege/
RevokeRolePrivilege instead of discarding it, matching every other
converted call site.

Verified against a live MySQL 8.0 container with an actual anonymous
account (''@'localhost'): sync succeeds and its grant is correctly
attributed to principal "@localhost".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SplitUserHost now accepts the empty username of MySQL's anonymous
account (''@'host'), but escapeMySQLUserHost's regex still required
one-or-more characters, so Grant/Revoke/CreateUser/DropUser against
that account failed one step later with "invalid user/host: ".//

Every call site feeds escapeMySQLUserHost values derived from
SplitUserHost, which already guarantees the host half is non-empty, so
loosening the regex to zero-or-more only ever affects the anonymous
account's empty username -- it can't accidentally allow an empty host.

Verified against a live MySQL 8.0 container: GrantDatabasePrivilege,
RevokeDatabasePrivilege, and DropUser all now succeed against a real
''@'localhost' account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@OneWhoNests
OneWhoNests force-pushed the fix/username-with-at-sign branch from c01af17 to 8abb097 Compare August 21, 2026 21:56
Comment thread pkg/connector/user.go
Comment thread pkg/client/helper.go Outdated

@github-actions github-actions Bot left a comment

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.

Blocking issues found — see review comments.

… hosts

Three findings from review:

- CreateAccount built client.User without UserType, so GetID() returned
  ":user@host" instead of "user:user@host". The old
  strings.Split(id, ":")[1] in Delete tolerated that; TrimPrefix does
  not, so the ID stayed ":user@host", SplitUserHost yielded ":user",
  and escapeMySQLUserHost rejected the ":" -- deleting a freshly
  provisioned account failed. The malformed ID was a pre-existing
  problem in its own right; setting UserType fixes both.

- CreateAccount took username from the account profile with only a type
  assertion. Now that escapeMySQLUserHost accepts the empty string, an
  empty username would provision MySQL's anonymous account (''@'host').
  Guard against it: empty names are legitimate to read and delete, never
  to create.

- validUserHost rejected ":" and "/", which are legal in MySQL host
  specs -- IPv6 literals (the stock root@::1) and netmask forms
  (198.51.100.0/255.255.255.0). Such accounts synced but every
  grant/revoke/drop against them failed with "invalid user/host". Both
  characters are inert inside the single-quoted '%s'@'%s' the callers
  build; "'" and "\" remain excluded.

Verified against a live MySQL 8.0 container: grant/revoke round-trips
for a v6user@::1 and a netuser@198.51.100.0/255.255.255.0 account, the
CreateAccount composite-ID round trip through to DropUser, and that
quote-injection and trailing-backslash inputs are still rejected. Full
sync over all four edge-case account shapes exits clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OneWhoNests
OneWhoNests dismissed github-actions[bot]’s stale review August 21, 2026 22:08

All three findings from this review were fixed in commit ea0c493 and verified against a live MySQL 8.0 container: (1) CreateAccount now sets UserType so GetID() yields a parseable 'user:name@host' composite ID -- this was a real bug, reproduced and confirmed; (2) CreateAccount now guards against an empty username so the anonymous account can never be provisioned; (3) validUserHost now permits ':' and '/' for IPv6 literals and netmask host specs, while still rejecting quote and backslash. Each thread has been replied to and resolved.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Comment thread pkg/client/helper.go
// literals (the stock root@::1) and netmask forms (198.51.100.0/255.255.255.0);
// both are inert inside the single-quoted '%s'@'%s' the callers build. "'" and
// "\" stay excluded, as those are what could break out of that quoting.
var validUserHost = regexp.MustCompile(`^[a-zA-Z0-9_%.@:/\-]*$`)

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: Widening the regex unblocks IPv6 at the escape layer, but the connector-side principal-ID parsing still splits on :, so IPv6 hosts remain unreachable for grant/revoke. For user:root@::1, pkg/connector/server.go:77, database.go:85, and column.go:80 take Split(id, ":")[1]"root@" (then SplitUserHost errors), while table.go:91, routine.go:106, role.go:96, and column.go:105 hit their len(parts) != 2 guard and return invalid principal ID. Netmask hosts are fine since / isn't a separator. Consider strings.TrimPrefix(id, resourceType+":") at those sites, as already done in grants.go:25 and user.go:179.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant