Skip to content

feat(users-roles): manage database users, roles, and privileges for MySQL and PostgreSQL#1853

Merged
datlechin merged 17 commits into
mainfrom
feat/users-roles-management
Jul 12, 2026
Merged

feat(users-roles): manage database users, roles, and privileges for MySQL and PostgreSQL#1853
datlechin merged 17 commits into
mainfrom
feat/users-roles-management

Conversation

@datlechin

Copy link
Copy Markdown
Member

Closes #1413.

Adds a Users & Roles tab for MySQL/MariaDB and PostgreSQL: browse the accounts on a server, see and edit their privileges from the server down through databases, schemas, tables, and columns, and create, alter, drop, and set passwords. Changes are staged, undoable, and shown as the exact SQL before they run.

Open it from View > Users & Roles. The command is disabled on drivers that do not support it.

Design

The privilege editor is a scope tree plus a privilege checklist, not a matrix. A principal x privilege grid collapses at real scale (MySQL has ~30 static plus ~40 dynamic privileges), and one click on a "Server" row would grant SUPER, SHUTDOWN, FILE and 40 more at once. Instead: an NSOutlineView of objects, lazily loaded, beside a checklist scoped to what is actually grantable on the selected object. The tree carries one summary column, which is what the HIG allows an outline view to do, and it answers "what does this account have on shop.orders?" without a click.

The UI does not lie about access. An unchecked box is not the same as "this user cannot do this". An Effective column resolves both inheritance axes:

  • Role membership, followed transitively, and honest about NOINHERIT (the privilege only applies after SET ROLE).
  • Parent-scope cascade, which is engine-specific and comes from the driver rather than a guess: a MySQL database-level grant covers the tables inside it, a PostgreSQL one does not.

PostgreSQL cannot read table ACLs for a database it is not connected to. Those databases still appear and still show their cluster-wide database-level grants, marked as not browsable. They are never shown as though the account had no privileges there.

Changes are a baseline plus a delta. Refresh (⌘R) re-reads the server and rebases: intent the server already satisfies quietly disappears. Unchecking a box you just checked cancels out instead of emitting a redundant REVOKE, and a grant that was grantable to others stays grantable.

Plugin API

Five new requirements on PluginPrincipalManagement, all with default implementations:

  • privilegeCascades(from:to:) and restrictsGrantBrowsingToCurrentDatabase drive the two points above.
  • supportsGrantableScopeSearch / searchGrantableScopes(matching:limit:) back the object search.
  • rollsBackPrincipalStatements exists because MySQL implicit-commits DDL. Claiming a rollback that did not happen would be a lie, so a partial failure reports how many statements actually landed and rebases the diff to the truth.

scripts/check-pluginkit-abi.sh reports the change as purely additive: no symbol removed, no initializer signature changed. No currentPluginKitVersion bump and no plugin re-release.

Safety

  • Every mutation routes through ExecutionGateProvider.authorize, so read-only connections and Safe Mode govern it exactly as they govern any other write. Drops force .destructiveQuery.
  • Identifiers are quoted through the driver. Privilege names are validated against an allowlist before they reach SQL, since they cannot be quoted.
  • MySQL treats _ and % in the database position as LIKE wildcards for global and database-level grants, but as literal characters in a table-level target. Both directions are handled, and round-tripped in tests.
  • CREATE USER / ALTER USER statements embed a plaintext password, so they are never written to the on-disk query history.
  • A change that removes access for the account the connection is using is warned about in the review sheet. It is not blocked, since revoking your own admin privileges can be deliberate.

Scope

Not covered, deliberately: editing WITH GRANT OPTION (it is displayed, and provably never stripped), ALTER DEFAULT PRIVILEGES, MySQL 8 roles, and renaming an account. Each is documented in docs/features/users-roles.mdx with its reason.

Testing

Unit tests cover the change model (delta semantics, rebase, undo), effective-privilege resolution under both engines' cascade rules, transitive and cycle-safe role closure, scope summaries, the privilege catalog, password generation, and MySQL grant parsing and escaping round-trips.

swiftlint lint --strict is clean. No UI automation for the privilege grid yet; the existing TableProUITests has no pattern for driving a custom NSOutlineView.

Note for the reviewer

TablePro/Resources/Localizable.xcstrings is not in this PR. Xcode regenerates it on build; committing the regenerated file here would have mixed in unrelated churn from the working tree. It should be regenerated and committed separately.

datlechin added 16 commits July 12, 2026 12:18
@mintlify

mintlify Bot commented Jul 12, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 12, 2026, 11:57 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Jul 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 374619cbc2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


static let defaultHost = "%"

private static let excludedPrivileges: Set<String> = ["GRANT OPTION", "PROXY"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude MySQL USAGE from editable grants

When SHOW PRIVILEGES includes MySQL's USAGE pseudo-privilege, this catalog only filters GRANT OPTION and PROXY, so every no-privilege account parsed from SHOW GRANTS can show a server-level USAGE grant and the UI may stage meaningless/invalid REVOKE USAGE changes. Treat USAGE like the other non-editable privilege names so it does not appear as a grantable privilege.

Useful? React with 👍 / 👎.

Comment on lines +76 to +80
if let reassignTarget = options.reassignOwnedTo {
statements.append("REASSIGN OWNED BY \(role) TO \(quoteIdentifier(reassignTarget.name))")
statements.append("DROP OWNED BY \(role)")
} else if options.dropOwned {
statements.append("DROP OWNED BY \(role)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle cross-database owned objects before DROP ROLE

For PostgreSQL roles that own objects in another database, principalOwnsObjects can detect the ownership through pg_shdepend, but the generated REASSIGN OWNED / DROP OWNED statements here only affect the current database before DROP ROLE runs. In that scenario the review flow promises reassignment or deletion, yet DROP ROLE still fails on the remaining dependencies; scope the ownership check to the current database or perform the cleanup in every database that has owned objects.

Useful? React with 👍 / 👎.

Comment on lines +61 to +63
private var duplicateName: Bool {
viewModel.principalRows.contains { $0.info.ref.name == trimmedName }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare MySQL duplicate accounts by host too

When host scoping is enabled, MySQL allows distinct accounts such as alice@% and alice@localhost, but this duplicate check only compares the user name. Creating a valid second account for a different host is therefore blocked by the sheet; include the host in the comparison whenever supportsPrincipalHostScoping is true.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00786c126f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +84 to +89
onOpenInEditor: {
coordinator?.loadQueryIntoEditor(
viewModel.previewSQL
.map { $0.hasSuffix(";") ? $0 : $0 + ";" }
.joined(separator: "\n\n")
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Block credential statements from the query editor

When the review includes CREATE USER / ALTER USER password SQL, previewSQL contains the plaintext password; sending it to loadQueryIntoEditor puts that text in a normal query tab, and normal tabs are persisted via PersistedTab.query by the tab persistence flow. This bypasses the new carriesCredentials history protection and can write database credentials to disk as soon as tab state is saved, so disable or redact this action for credential-carrying statements.

Useful? React with 👍 / 👎.

Comment on lines +246 to +248
.onAppear {
target = viewModel.connectedPrincipal ?? prompt.reassignCandidates.first
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Select only valid reassignment candidates

When the connected PostgreSQL role is also one of the roles being dropped and it owns objects, requestDrop excludes it from prompt.reassignCandidates, but this still seeds target with connectedPrincipal. The picker has no matching row while disposition is non-nil, so the dialog can generate REASSIGN OWNED ... TO the same/dropped role followed by DROP ROLE, which fails; only default to the connected principal if it is actually in the candidate list.

Useful? React with 👍 / 👎.

Comment on lines +189 to +192
let owningRefs = Set(owning.map(\.ref))
let candidates = changeManager.principals
.map(\.ref)
.filter { !owningRefs.contains($0) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude every drop target from reassignment

In a multi-drop where role A owns objects and role B does not, B is staged for drop during the ownership check but remains in this candidate list because only owning roles are filtered out. If the user reassigns A's objects to B, applying the staged changes either targets a role already being dropped or makes B own objects just before its DROP ROLE, so the batch fails; exclude all requested refs and already-pending drops from reassignment candidates.

Useful? React with 👍 / 👎.

Comment on lines +317 to +319
private func apply() {
guard let info = viewModel.changeManager.principals.first(where: { $0.ref == principal })
else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fold memberships into staged creates

When editing “Member Of” for a principal that was just created in this session, it exists only in pendingCreates, so this guard returns and the Done button silently discards the selected memberships. Since stageAlter already knows how to fold edits into a staged create and the create SQL emits definition.memberOf, build the current definition from pendingCreates as well as existing principals.

Useful? React with 👍 / 👎.

Comment on lines +19 to +22
let closure = PrivilegeEffectivenessResolver.roleClosure(
for: principal,
principals: principals
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Base effective roles on staged memberships

After a PostgreSQL membership edit is staged, the new memberOf list lives in pendingAlters, but effective privilege resolution still computes the role closure from the original principals snapshot only. The Effective column therefore continues to show privileges from removed roles, or omits privileges from newly added roles, until apply/reload; merge pending alters into the principal data before resolving inheritance.

Useful? React with 👍 / 👎.

@datlechin datlechin merged commit d0d8305 into main Jul 12, 2026
3 checks passed
@datlechin datlechin deleted the feat/users-roles-management branch July 12, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Role/User management

1 participant