Skip to content

Commit 75ddc6c

Browse files
souravcrlclaude
andcommitted
sql: implement DROP PROVISIONED ROLES execution
Release note: None Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4edf293 commit 75ddc6c

3 files changed

Lines changed: 503 additions & 0 deletions

File tree

pkg/sql/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ go_library(
106106
"drop_external_connection.go",
107107
"drop_function.go",
108108
"drop_index.go",
109+
"drop_provisioned_roles.go",
109110
"drop_role.go",
110111
"drop_schema.go",
111112
"drop_sequence.go",
@@ -704,6 +705,7 @@ go_test(
704705
"distsql_running_test.go",
705706
"drop_function_test.go",
706707
"drop_helpers_test.go",
708+
"drop_provisioned_roles_test.go",
707709
"drop_test.go",
708710
"err_count_test.go",
709711
"event_log_test.go",

pkg/sql/drop_provisioned_roles.go

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Copyright 2026 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
package sql
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"strings"
12+
13+
"github.com/cockroachdb/cockroach/pkg/security/username"
14+
"github.com/cockroachdb/cockroach/pkg/sql/catalog/nstree"
15+
"github.com/cockroachdb/cockroach/pkg/sql/lexbase"
16+
"github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgnotice"
17+
"github.com/cockroachdb/cockroach/pkg/sql/privilege"
18+
"github.com/cockroachdb/cockroach/pkg/sql/sem/catconstants"
19+
"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
20+
"github.com/cockroachdb/cockroach/pkg/sql/sessiondata"
21+
"github.com/cockroachdb/cockroach/pkg/sql/sessioninit"
22+
"github.com/cockroachdb/cockroach/pkg/sql/sqltelemetry"
23+
"github.com/cockroachdb/cockroach/pkg/util/log/eventpb"
24+
"github.com/cockroachdb/redact"
25+
)
26+
27+
// DropProvisionedRolesNode drops provisioned users matching filter
28+
// criteria (SOURCE, LAST LOGIN BEFORE) with an optional
29+
// LIMIT. Users that own objects or have dependencies are skipped with
30+
// a NOTICE rather than failing the entire operation.
31+
type DropProvisionedRolesNode struct {
32+
zeroInputPlanNode
33+
options *tree.DropProvisionedRolesOptions
34+
limit *tree.Limit
35+
}
36+
37+
// DropProvisionedRoles creates a plan node for DROP PROVISIONED ROLES.
38+
// Requires CREATEROLE privilege.
39+
func (p *planner) DropProvisionedRoles(
40+
ctx context.Context, n *tree.DropProvisionedRoles,
41+
) (planNode, error) {
42+
if err := p.CheckGlobalPrivilegeOrRoleOption(ctx, privilege.CREATEROLE); err != nil {
43+
return nil, err
44+
}
45+
return &DropProvisionedRolesNode{
46+
options: n.Options,
47+
limit: n.Limit,
48+
}, nil
49+
}
50+
51+
func (n *DropProvisionedRolesNode) startExec(params runParams) error {
52+
sqltelemetry.IncIAMDropCounter(sqltelemetry.User)
53+
const opName redact.RedactableString = "drop-provisioned-roles"
54+
55+
hasAdmin, err := params.p.HasAdminRole(params.ctx)
56+
if err != nil {
57+
return err
58+
}
59+
60+
// Build the query to find matching provisioned users.
61+
query, queryArgs := n.buildFilterQuery()
62+
63+
rows, err := params.p.InternalSQLTxn().QueryBufferedEx(
64+
params.ctx,
65+
"drop-provisioned-roles-find",
66+
params.p.txn,
67+
sessiondata.NodeUserSessionDataOverride,
68+
query,
69+
queryArgs...,
70+
)
71+
if err != nil {
72+
return err
73+
}
74+
75+
// Collect all descriptors once for dependency checking.
76+
allDescs, err := params.p.Descriptors().GetAllDescriptors(
77+
params.ctx, params.p.txn,
78+
)
79+
if err != nil {
80+
return err
81+
}
82+
83+
var numDropped, numSkipped int
84+
var droppedNames []string
85+
var numRoleSettingsRowsDeleted int
86+
87+
for _, row := range rows {
88+
normalizedUsername := username.MakeSQLUsernameFromPreNormalizedString(
89+
string(tree.MustBeDString(row[0])),
90+
)
91+
92+
// Skip reserved roles.
93+
if normalizedUsername.IsAdminRole() ||
94+
normalizedUsername.IsPublicRole() ||
95+
normalizedUsername.IsRootUser() {
96+
continue
97+
}
98+
99+
// Non-admin users cannot drop admins.
100+
if !hasAdmin {
101+
targetIsAdmin, err := params.p.UserHasAdminRole(
102+
params.ctx, normalizedUsername,
103+
)
104+
if err != nil {
105+
return err
106+
}
107+
if targetIsAdmin {
108+
params.p.BufferClientNotice(
109+
params.ctx,
110+
pgnotice.Newf("skipping %q: must be superuser to drop superusers", normalizedUsername),
111+
)
112+
numSkipped++
113+
continue
114+
}
115+
}
116+
117+
// Check for dependencies (owned objects, grants, default
118+
// privileges, scheduled jobs, system privileges).
119+
if hasDeps, err := n.userHasDependencies(
120+
params, normalizedUsername, allDescs,
121+
); err != nil {
122+
return err
123+
} else if hasDeps {
124+
params.p.BufferClientNotice(
125+
params.ctx,
126+
pgnotice.Newf("skipping %q: role has dependent objects", normalizedUsername),
127+
)
128+
numSkipped++
129+
continue
130+
}
131+
132+
// Delete the role from all system tables.
133+
deleted, err := n.deleteRole(params, normalizedUsername, opName)
134+
if err != nil {
135+
return err
136+
}
137+
numRoleSettingsRowsDeleted += deleted
138+
numDropped++
139+
droppedNames = append(droppedNames, normalizedUsername.Normalized())
140+
}
141+
142+
// Bump table versions if anything was dropped.
143+
if numDropped > 0 {
144+
if sessioninit.CacheEnabled.Get(&params.p.ExecCfg().Settings.SV) {
145+
if err := params.p.bumpUsersTableVersion(params.ctx); err != nil {
146+
return err
147+
}
148+
if err := params.p.bumpRoleOptionsTableVersion(params.ctx); err != nil {
149+
return err
150+
}
151+
if numRoleSettingsRowsDeleted > 0 {
152+
if err := params.p.bumpDatabaseRoleSettingsTableVersion(params.ctx); err != nil {
153+
return err
154+
}
155+
}
156+
}
157+
if err := params.p.BumpRoleMembershipTableVersion(params.ctx); err != nil {
158+
return err
159+
}
160+
161+
// Log per-user DropRole events.
162+
for _, name := range droppedNames {
163+
if err := params.p.logEvent(params.ctx,
164+
0, /* no target */
165+
&eventpb.DropRole{RoleName: name}); err != nil {
166+
return err
167+
}
168+
}
169+
}
170+
171+
return nil
172+
}
173+
174+
// buildFilterQuery constructs the SQL query to find provisioned users
175+
// matching the filter options.
176+
func (n *DropProvisionedRolesNode) buildFilterQuery() (string, []interface{}) {
177+
var whereExprs []string
178+
var args []interface{}
179+
argIdx := 1
180+
181+
// Always filter for users that have a PROVISIONSRC role option
182+
// (i.e. are provisioned).
183+
provisionFilter := fmt.Sprintf(`EXISTS (
184+
SELECT 1 FROM system.role_options AS src
185+
WHERE src.username = u.username
186+
AND src.option = 'PROVISIONSRC'`)
187+
188+
if n.options != nil && n.options.Source != nil {
189+
sourceStr := tree.AsStringWithFlags(
190+
n.options.Source, tree.FmtBareStrings,
191+
)
192+
provisionFilter += fmt.Sprintf(
193+
"\n\t\tAND src.value = %s", lexbase.EscapeSQLString(sourceStr),
194+
)
195+
}
196+
provisionFilter += "\n)"
197+
whereExprs = append(whereExprs, provisionFilter)
198+
199+
if n.options != nil && n.options.LastLoginBefore != nil {
200+
tsExpr := tree.AsStringWithFlags(
201+
n.options.LastLoginBefore, tree.FmtParsable,
202+
)
203+
whereExprs = append(whereExprs, fmt.Sprintf(
204+
"u.estimated_last_login_time < (%s)::TIMESTAMPTZ", tsExpr,
205+
))
206+
}
207+
208+
whereClause := "\nWHERE " + strings.Join(whereExprs, "\n\tAND ")
209+
210+
var limitClause string
211+
if n.limit != nil && n.limit.Count != nil {
212+
limitClause = fmt.Sprintf("\nLIMIT %s", tree.AsString(n.limit.Count))
213+
}
214+
215+
query := fmt.Sprintf(
216+
`SELECT u.username FROM system.users AS u%s%s`,
217+
whereClause, limitClause,
218+
)
219+
220+
_ = argIdx // args currently embedded via string escaping
221+
return query, args
222+
}
223+
224+
// userHasDependencies checks whether the given user owns any objects,
225+
// has grants, default privileges, scheduled jobs, or system
226+
// privileges that would prevent dropping.
227+
func (n *DropProvisionedRolesNode) userHasDependencies(
228+
params runParams, normalizedUsername username.SQLUsername, allDescs nstree.Catalog,
229+
) (bool, error) {
230+
// Check ownership across all descriptors.
231+
for _, desc := range allDescs.OrderedDescriptors() {
232+
if !descriptorIsVisible(desc, true /* allowAdding */, false /* includeDropped */) {
233+
continue
234+
}
235+
if desc.GetPrivileges().Owner() == normalizedUsername {
236+
return true, nil
237+
}
238+
for _, u := range desc.GetPrivileges().Users {
239+
if u.User() == normalizedUsername {
240+
return true, nil
241+
}
242+
}
243+
}
244+
245+
// Check scheduled jobs.
246+
row, err := params.p.InternalSQLTxn().QueryRowEx(
247+
params.ctx,
248+
"check-user-schedules",
249+
params.p.txn,
250+
sessiondata.NodeUserSessionDataOverride,
251+
"SELECT count(*) FROM system.scheduled_jobs WHERE owner=$1",
252+
normalizedUsername,
253+
)
254+
if err != nil {
255+
return false, err
256+
}
257+
if row != nil && int64(tree.MustBeDInt(row[0])) > 0 {
258+
return true, nil
259+
}
260+
261+
// Check system privileges.
262+
row, err = params.p.InternalSQLTxn().QueryRowEx(
263+
params.ctx,
264+
"check-user-system-privileges",
265+
params.p.txn,
266+
sessiondata.NodeUserSessionDataOverride,
267+
"SELECT count(*) FROM system.privileges WHERE username=$1",
268+
normalizedUsername.Normalized(),
269+
)
270+
if err != nil {
271+
return false, err
272+
}
273+
if row != nil && int64(tree.MustBeDInt(row[0])) > 0 {
274+
return true, nil
275+
}
276+
277+
return false, nil
278+
}
279+
280+
// deleteRole removes a single role from all system tables and revokes
281+
// its web sessions.
282+
func (n *DropProvisionedRolesNode) deleteRole(
283+
params runParams, normalizedUsername username.SQLUsername, opName redact.RedactableString,
284+
) (dbRoleSettingsDeleted int, err error) {
285+
// DELETE from system.users.
286+
if _, err = params.p.InternalSQLTxn().ExecEx(
287+
params.ctx, opName, params.p.txn,
288+
sessiondata.NodeUserSessionDataOverride,
289+
`DELETE FROM system.users WHERE username=$1`,
290+
normalizedUsername,
291+
); err != nil {
292+
return 0, err
293+
}
294+
295+
// DELETE from system.role_members.
296+
if _, err = params.p.InternalSQLTxn().ExecEx(
297+
params.ctx, "drop-role-membership", params.p.txn,
298+
sessiondata.NodeUserSessionDataOverride,
299+
`DELETE FROM system.role_members WHERE "role" = $1 OR "member" = $1`,
300+
normalizedUsername,
301+
); err != nil {
302+
return 0, err
303+
}
304+
305+
// DELETE from system.role_options.
306+
if _, err = params.p.InternalSQLTxn().ExecEx(
307+
params.ctx, opName, params.p.txn,
308+
sessiondata.NodeUserSessionDataOverride,
309+
fmt.Sprintf(
310+
`DELETE FROM system.public.%s WHERE username=$1`,
311+
catconstants.RoleOptionsTableName,
312+
),
313+
normalizedUsername,
314+
); err != nil {
315+
return 0, err
316+
}
317+
318+
// DELETE from system.database_role_settings.
319+
rowsDeleted, err := params.p.InternalSQLTxn().ExecEx(
320+
params.ctx, opName, params.p.txn,
321+
sessiondata.NodeUserSessionDataOverride,
322+
fmt.Sprintf(
323+
`DELETE FROM system.public.%s WHERE role_name = $1`,
324+
catconstants.DatabaseRoleSettingsTableName,
325+
),
326+
normalizedUsername,
327+
)
328+
if err != nil {
329+
return 0, err
330+
}
331+
332+
// Revoke web sessions.
333+
if _, err = params.p.InternalSQLTxn().ExecEx(
334+
params.ctx, opName, params.p.txn,
335+
sessiondata.NodeUserSessionDataOverride,
336+
`UPDATE system.web_sessions SET "revokedAt" = now() WHERE username = $1 AND "revokedAt" IS NULL`,
337+
normalizedUsername,
338+
); err != nil {
339+
return 0, err
340+
}
341+
342+
return rowsDeleted, nil
343+
}
344+
345+
// Next implements the planNode interface.
346+
func (*DropProvisionedRolesNode) Next(runParams) (bool, error) { return false, nil }
347+
348+
// Values implements the planNode interface.
349+
func (*DropProvisionedRolesNode) Values() tree.Datums { return tree.Datums{} }
350+
351+
// Close implements the planNode interface.
352+
func (*DropProvisionedRolesNode) Close(context.Context) {}

0 commit comments

Comments
 (0)