Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sql,backupccl: allow owners to also control schedules #87287

Merged
merged 1 commit into from
Sep 7, 2022
Merged
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
20 changes: 10 additions & 10 deletions pkg/ccl/backupccl/alter_backup_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ func loadSchedules(
if err != nil {
return s, err
}

args := &backuppb.ScheduledBackupExecutionArgs{}
if err := pbtypes.UnmarshalAny(schedule.ExecutionArgs().Args, args); err != nil {
return s, errors.Wrap(err, "un-marshaling args")
Expand Down Expand Up @@ -109,8 +108,7 @@ func doAlterBackupSchedules(
eval *alterBackupScheduleEval,
resultsCh chan<- tree.Datums,
) error {
s, err := loadSchedules(
ctx, p, eval)
s, err := loadSchedules(ctx, p, eval)
if err != nil {
return err
}
Expand All @@ -126,14 +124,16 @@ func doAlterBackupSchedules(
s.incJob.ScheduleID())
}

// Note that even ADMIN is subject to these restrictions. We expect to
// add a finer-grained permissions model soon.
if s.fullJob.Owner() != p.User() {
return pgerror.Newf(pgcode.InsufficientPrivilege, "only the OWNER of a schedule may alter it")
// Check that the user is admin or the owner of the schedules being altered.
isAdmin, err := p.UserHasAdminRole(ctx, p.User())
if err != nil {
return err
}

if s.incJob != nil && s.incJob.Owner() != p.User() {
return pgerror.Newf(pgcode.InsufficientPrivilege, "only the OWNER of a schedule may alter it")
isOwnerOfFullJob := s.fullJob == nil || s.fullJob.Owner() == p.User()
isOwnerOfIncJob := s.incJob == nil || s.incJob.Owner() == p.User()
if !isAdmin && !(isOwnerOfFullJob && isOwnerOfIncJob) {
return pgerror.New(pgcode.InsufficientPrivilege, "must be admin or owner of the "+
"schedules being altered.")
}

if s, err = processFullBackupRecurrence(
Expand Down
122 changes: 113 additions & 9 deletions pkg/ccl/backupccl/testdata/backup-restore/schedule-privileges
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
new-server name=s1
----

subtest create-scheduled-privileges

exec-sql
CREATE DATABASE foo;
----
Expand All @@ -15,15 +17,15 @@ CREATE EXTERNAL CONNECTION foo AS 'userfile:///foo';

# Admin can create all schedules.
exec-sql
CREATE SCHEDULE foocluster FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
CREATE SCHEDULE foocluster_admin FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
----

exec-sql
CREATE SCHEDULE foodb FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
CREATE SCHEDULE foodb_admin FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
----

exec-sql
CREATE SCHEDULE footable FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
CREATE SCHEDULE footable_admin FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
----

# Non-root admin can create all schedules.
Expand All @@ -33,15 +35,15 @@ GRANT ADMIN TO testuser;
----

exec-sql user=testuser
CREATE SCHEDULE foocluster FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
CREATE SCHEDULE foocluster_admintestuser FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
----

exec-sql user=testuser
CREATE SCHEDULE foodb FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
CREATE SCHEDULE foodb_admintestuser FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
----

exec-sql user=testuser
CREATE SCHEDULE footable FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
CREATE SCHEDULE footable_admintestuser FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
----

# Non-root non-admin cannot create any schedules.
Expand Down Expand Up @@ -78,13 +80,115 @@ GRANT USAGE ON EXTERNAL CONNECTION foo TO testuser;
----

exec-sql user=testuser
CREATE SCHEDULE foocluster FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
CREATE SCHEDULE foocluster_testuser FOR BACKUP INTO 'external://foo/cluster' RECURRING '@hourly';
----

exec-sql user=testuser
CREATE SCHEDULE foodb FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
CREATE SCHEDULE foodb_testuser FOR BACKUP DATABASE foo INTO 'external://foo/database' RECURRING '@hourly';
----

exec-sql user=testuser
CREATE SCHEDULE footable FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
CREATE SCHEDULE footable_testuser FOR BACKUP TABLE foo.foo INTO 'external://foo/table' RECURRING '@hourly';
----

subtest end

subtest control-schedules-privileges

# Save the schedule IDs for the schedules created by admin root.
let $fullID $incID
with schedules as (show schedules) select id from schedules where label='foocluster_admin' order by command->>'backup_type' asc;
----

query-sql
with schedules as (SHOW SCHEDULES FOR BACKUP) SELECT label, command FROM schedules WHERE id IN
($fullID, $incID) ORDER BY next_run;
----
foocluster_admin BACKUP INTO LATEST IN 'external://foo/cluster' WITH detached
foocluster_admin BACKUP INTO 'external://foo/cluster' WITH detached

# nonadmin testuser is not allowed to drop a schedule they do not own.
exec-sql expect-error-regex=(must be admin or owner of the schedule [0-9]+ to DROP it) user=testuser
DROP SCHEDULE $fullID
----
regex matches error

exec-sql
GRANT ADMIN TO testuser
----

# testuser has been granted admin so the drop should succeed.
exec-sql user=testuser
DROP SCHEDULE $fullID
----

query-sql
with schedules as (SHOW SCHEDULES FOR BACKUP) SELECT label,command,owner FROM schedules WHERE id IN
($fullID, $incID) ORDER BY next_run;
----
foocluster_admin BACKUP INTO LATEST IN 'external://foo/cluster' WITH detached root

exec-sql
REVOKE ADMIN FROM testuser
----

exec-sql expect-error-regex=(must be admin or owner of the schedule [0-9]+ to DROP it) user=testuser
DROP SCHEDULE $incID
----
regex matches error

# Save the schedule IDs for the schedules that were created by non-admin testuser.
let $testuserFullID $testuserIncID
with schedules as (show schedules) select id from schedules where label='foocluster_testuser' order by command->>'backup_type' asc;
----

query-sql
with schedules as (SHOW SCHEDULES FOR BACKUP) SELECT label, command, owner FROM schedules WHERE id
IN ($testuserFullID, $testuserIncID) ORDER BY next_run;
----
foocluster_testuser BACKUP INTO LATEST IN 'external://foo/cluster' WITH detached testuser
foocluster_testuser BACKUP INTO 'external://foo/cluster' WITH detached testuser

# testuser owns these schedules so should be able to pause, resume, drop, alter
# them without admin.
exec-sql user=testuser
PAUSE SCHEDULE $testuserFullID;
PAUSE SCHEDULE $testuserIncID;
----

exec-sql user=testuser
RESUME SCHEDULE $testuserFullID;
RESUME SCHEDULE $testuserIncID;
----

exec-sql user=testuser
ALTER BACKUP SCHEDULE $testuserFullID SET WITH revision_history = false;
----

exec-sql user=testuser
DROP SCHEDULE $testuserFullID;
DROP SCHEDULE $testuserIncID;
----

# But testuser can't drop, alter, resume or pause the root owned schedules.
exec-sql expect-error-regex=(must be admin or owner of the schedule [0-9]+ to PAUSE it) user=testuser
PAUSE SCHEDULE $incID
----
regex matches error

exec-sql expect-error-regex=(must be admin or owner of the schedule [0-9]+ to RESUME it) user=testuser
RESUME SCHEDULE $incID
----
regex matches error

exec-sql user=testuser expect-error-regex=(incremental backup schedule [0-9]+ has no corresponding full backup schedule)
ALTER BACKUP SCHEDULE $incID SET WITH revision_history = false;
----
regex matches error

exec-sql expect-error-regex=(must be admin or owner of the schedule [0-9]+ to DROP it) user=testuser
DROP SCHEDULE $incID;
----
regex matches error

subtest end
20 changes: 0 additions & 20 deletions pkg/jobs/delegate_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ package jobs

import (
"context"
gosql "database/sql"
"fmt"
"net/url"
"strings"
"testing"
"time"
Expand All @@ -25,7 +23,6 @@ import (
"github.com/cockroachdb/cockroach/pkg/security/username"
"github.com/cockroachdb/cockroach/pkg/settings/cluster"
"github.com/cockroachdb/cockroach/pkg/sql/sessiondata"
"github.com/cockroachdb/cockroach/pkg/testutils/sqlutils"
"github.com/cockroachdb/cockroach/pkg/util/leaktest"
"github.com/cockroachdb/cockroach/pkg/util/timeutil"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -138,23 +135,6 @@ func TestScheduleControl(t *testing.T) {
th.sqlDB.Exec(t, "DROP SCHEDULES "+querySchedules)
require.Equal(t, 0, len(th.sqlDB.QueryStr(t, querySchedules)))
})

t.Run("pause-non-privileged-user", func(t *testing.T) {
scheduleID := makeSchedule("one-schedule", "@daily")

th.sqlDB.Exec(t, `CREATE USER testuser`)
pgURL, cleanupFunc := sqlutils.PGUrl(
t, th.server.ServingSQLAddr(), "NonPrivileged-testuser",
url.User("testuser"),
)
defer cleanupFunc()
testuser, err := gosql.Open("postgres", pgURL.String())
require.NoError(t, err)
defer testuser.Close()

_, err = testuser.Exec("PAUSE SCHEDULE $1", scheduleID)
require.EqualError(t, err, "pq: only users with the admin role are allowed to PAUSE SCHEDULES")
})
}

func TestJobsControlForSchedules(t *testing.T) {
Expand Down
21 changes: 18 additions & 3 deletions pkg/sql/control_schedules.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"github.com/cockroachdb/cockroach/pkg/scheduledjobs"
"github.com/cockroachdb/cockroach/pkg/security/username"
"github.com/cockroachdb/cockroach/pkg/server/telemetry"
"github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgcode"
"github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror"
"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
"github.com/cockroachdb/cockroach/pkg/sql/sessiondata"
"github.com/cockroachdb/cockroach/pkg/sql/sqltelemetry"
Expand Down Expand Up @@ -57,19 +59,22 @@ func JobSchedulerEnv(execCfg *ExecutorConfig) scheduledjobs.JobSchedulerEnv {
return scheduledjobs.ProdJobSchedulerEnv
}

// loadSchedule loads schedule information.
// loadSchedule loads schedule information as the node user.
func loadSchedule(params runParams, scheduleID tree.Datum) (*jobs.ScheduledJob, error) {
env := JobSchedulerEnv(params.ExecCfg())
schedule := jobs.NewScheduledJob(env)

// Load schedule expression. This is needed for resume command, but we
// also use this query to check for the schedule existence.
//
// Run the query as the node user since we perform our own privilege checks
// before using the returned schedule.
datums, cols, err := params.ExecCfg().InternalExecutor.QueryRowExWithCols(
params.ctx,
"load-schedule",
params.p.Txn(), sessiondata.InternalExecutorOverride{User: username.RootUserName()},
params.p.Txn(), sessiondata.InternalExecutorOverride{User: username.NodeUserName()},
Copy link
Collaborator

Choose a reason for hiding this comment

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

happy to support this, but why? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My understanding is that NodeUser is what we should be using for intra-cluster traffic -

const NodeUser = "node"
. It is the user used when a node is acting on its own behalf, rather than as an external user or an app running as root.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Should it be running on its own behalf? Or should loadSchedule take a context and run as the user executing the query?

More concretely, I guess, can we imagine a world where we want a user to see/load some schedules but not others? (yes, I think?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Or should loadSchedule take a context and run as the user executing the query?

This would be tricky because only admin is allowed to select against a system table. So a non-admin owner would get an error running loadSchedule. The scan of the system table is an internal implementation detail that expects other pieces of the logic to perform the appropriate privilege checks.

can we imagine a world where we want a user to see/load some schedules but not others

Yes, I think so, but "load" is an internal implementation detail so I think the filtering needs to be in the logic that uses the loaded schedule, which is what we're doing here. We are already allowing users to only alter a limited set of schedules if they are non-admin, i.e. the schedules they own. Similarly if we want to limit what schedules a user sees we should be adding this privilege check to SHOW SCHEDULES and other user facing queries that allow for introspection.

Copy link
Collaborator

@benbardin benbardin Sep 1, 2022

Choose a reason for hiding this comment

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

expects other pieces of the logic to perform the appropriate privilege checks

Yeah, exactly this - I'd worry about a developer forgetting that. It's not obvious to me that somebody calling something called loadSchedule() will be executing node-level privileges under the hood. That said, if it's obvious to you (i.e. common practice :) ) then I'm fine with it.

fmt.Sprintf(
"SELECT schedule_id, next_run, schedule_expr, executor_type, execution_args FROM %s WHERE schedule_id = $1",
"SELECT schedule_id, next_run, schedule_expr, executor_type, execution_args, owner FROM %s WHERE schedule_id = $1",
env.ScheduledJobsTableName(),
),
scheduleID)
Expand Down Expand Up @@ -136,6 +141,16 @@ func (n *controlSchedulesNode) startExec(params runParams) error {
continue // not an error if schedule does not exist
}

isAdmin, err := params.p.UserHasAdminRole(params.ctx, params.p.User())
if err != nil {
return err
}
isOwner := schedule.Owner() == params.p.User()
if !isAdmin && !isOwner {
return pgerror.Newf(pgcode.InsufficientPrivilege, "must be admin or owner of the "+
"schedule %d to %s it", schedule.ScheduleID(), n.command.String())
Copy link
Collaborator

Choose a reason for hiding this comment

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

is n.command.String() helpful info here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤷 it makes for a more informative error message that you're not allowed to RESUME, DROP, PAUSE it, but I guess you already know that because you're running the command.

}

switch n.command {
case tree.PauseSchedule:
schedule.Pause()
Expand Down
4 changes: 0 additions & 4 deletions pkg/sql/opt/optbuilder/misc_statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,6 @@ func (b *Builder) buildCancelSessions(n *tree.CancelSessions, inScope *scope) (o
func (b *Builder) buildControlSchedules(
n *tree.ControlSchedules, inScope *scope,
) (outScope *scope) {
if err := b.catalog.RequireAdminRole(b.ctx, n.StatementTag()); err != nil {
adityamaru marked this conversation as resolved.
Show resolved Hide resolved
panic(err)
}

// We don't allow the input statement to reference outer columns, so we
// pass a "blank" scope rather than inScope.
emptyScope := b.allocScope()
Expand Down