diff --git a/src/cas.rs b/src/cas.rs index 89a291daa7e6895e248c2adbd18c83d86d38b3e8..13b8adc32fca128d4e6c46fec0a9e6e8d9a8f250 100644 --- a/src/cas.rs +++ b/src/cas.rs @@ -375,7 +375,7 @@ fn proc_cas_local(req: &Request) -> Result<Response> { // We can't use a `do_acl` inside of a transaction here similarly to `do_dml`, // as acl should be applied only on replicaset leader. Raft leader is not always // a replicaset leader. - Op::Acl(acl) => acl.validate()?, + Op::Acl(acl) => acl.validate(storage)?, _ => (), } diff --git a/src/schema.rs b/src/schema.rs index a834a02d41cf0484f814b0ab92222531521fcbf5..9cccde3c45a4762c00e43cd69fe74ace292bfbf1 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -856,6 +856,53 @@ impl UserDef { ty: UserMetadataKind::User, } } + + pub fn ensure_no_dependent_objects(&self, storage: &Clusterwide) -> traft::Result<()> { + let tables: Vec<_> = storage + .tables + .by_owner_id(self.id)? + .map(|def| def.name) + .collect(); + + let routines: Vec<_> = storage + .routines + .by_owner_id(self.id)? + .map(|def| def.name) + .collect(); + + let users: Vec<_> = storage + .users + .by_owner_id(self.id)? + .filter(|def| def.ty == UserMetadataKind::User) + .map(|def| def.name) + .collect(); + + let roles: Vec<_> = storage + .users + .by_owner_id(self.id)? + .filter(|def| def.ty == UserMetadataKind::Role) + .map(|def| def.name) + .collect(); + + if tables.is_empty() && routines.is_empty() && roles.is_empty() && users.is_empty() { + return Ok(()); + } + + let mut err = "user cannot be dropped because some objects depend on it\n".to_string(); + if !tables.is_empty() { + err.push_str(&format!("owner of tables {}\n", tables.join(", "))); + } + if !routines.is_empty() { + err.push_str(&format!("owner of procedures {}\n", routines.join(", "))); + } + if !users.is_empty() { + err.push_str(&format!("owner of users {}\n", users.join(", "))); + } + if !roles.is_empty() { + err.push_str(&format!("owner of roles {}\n", roles.join(", "))); + } + Err(traft::error::Error::Other(err.into())) + } } //////////////////////////////////////////////////////////////////////////////// diff --git a/src/storage.rs b/src/storage.rs index 1e7ef8bc2d4706a7d0686472c4c0f5adefa1d6f1..6356679e64f9d18ce40d6376304c7af0a1a56c98 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -319,8 +319,9 @@ define_clusterwide_tables! { pub struct Tables { space: Space, #[primary] - index_id: Index => "_pico_table_id", - index_name: Index => "_pico_table_name", + index_id: Index => "_pico_table_id", + index_name: Index => "_pico_table_name", + index_owner_id: Index => "_pico_table_owner_id", } } Index = 513, "_pico_index" => { @@ -387,6 +388,7 @@ define_clusterwide_tables! { #[primary] index_id: Index => "_pico_user_id", index_name: Index => "_pico_user_name", + index_owner_id: Index => "_pico_user_owner_id", } } Privilege = 521, "_pico_privilege" => { @@ -418,8 +420,9 @@ define_clusterwide_tables! { pub struct Routines { space: Space, #[primary] - index_id: Index => "_pico_routine_id", - index_name: Index => "_pico_routine_name", + index_id: Index => "_pico_routine_id", + index_name: Index => "_pico_routine_name", + index_owner_id: Index => "_pico_routine_owner_id", } } Plugin = 526, "_pico_plugin" => { @@ -2379,10 +2382,18 @@ impl Tables { .if_not_exists(true) .create()?; + let index_owner_id = space + .index_builder("_pico_table_owner_id") + .unique(false) + .part("owner") + .if_not_exists(true) + .create()?; + Ok(Self { space, index_id, index_name, + index_owner_id, }) } @@ -2417,6 +2428,17 @@ impl Tables { // This means the local schema is already up to date and main loop doesn't need to do anything schema_version: INITIAL_SCHEMA_VERSION, }, + IndexDef { + table_id: Self::TABLE_ID, + id: 2, + name: "_pico_table_owner_id".into(), + ty: IndexType::Tree, + opts: vec![IndexOption::Unique(false)], + parts: vec![Part::from(("owner", IndexFieldType::Unsigned)).is_nullable(false)], + operable: true, + // This means the local schema is already up to date and main loop doesn't need to do anything + schema_version: INITIAL_SCHEMA_VERSION, + }, ] } @@ -2475,6 +2497,14 @@ impl Tables { Ok(None) } } + + pub fn by_owner_id( + &self, + owner_id: UserId, + ) -> tarantool::Result<EntryIter<TableDef, MP_CUSTOM>> { + let iter = self.index_owner_id.select(IteratorType::Eq, &[owner_id])?; + Ok(EntryIter::new(iter)) + } } impl ToEntryIter<MP_CUSTOM> for Tables { @@ -3052,10 +3082,18 @@ impl Users { .if_not_exists(true) .create()?; + let index_owner_id = space + .index_builder("_pico_user_owner_id") + .unique(false) + .part("owner") + .if_not_exists(true) + .create()?; + Ok(Self { space, index_id, index_name, + index_owner_id, }) } @@ -3090,6 +3128,17 @@ impl Users { // This means the local schema is already up to date and main loop doesn't need to do anything schema_version: INITIAL_SCHEMA_VERSION, }, + IndexDef { + table_id: Self::TABLE_ID, + id: 2, + name: "_pico_user_owner_id".into(), + ty: IndexType::Tree, + opts: vec![IndexOption::Unique(false)], + parts: vec![Part::from(("owner", IndexFieldType::Unsigned)).is_nullable(false)], + operable: true, + // This means the local schema is already up to date and main loop doesn't need to do anything + schema_version: INITIAL_SCHEMA_VERSION, + }, ] } @@ -3105,6 +3154,12 @@ impl Users { tuple.as_ref().map(Tuple::decode).transpose() } + #[inline] + pub fn by_owner_id(&self, owner_id: UserId) -> tarantool::Result<EntryIter<UserDef, MP_SERDE>> { + let iter = self.index_owner_id.select(IteratorType::Eq, &[owner_id])?; + Ok(EntryIter::new(iter)) + } + #[inline] pub fn max_user_id(&self) -> tarantool::Result<Option<UserId>> { match self.index_id.max(&())? { @@ -3455,10 +3510,18 @@ impl Routines { .if_not_exists(true) .create()?; + let index_owner_id = space + .index_builder("_pico_routine_owner_id") + .unique(false) + .part("owner") + .if_not_exists(true) + .create()?; + Ok(Self { space, index_id, index_name, + index_owner_id, }) } @@ -3493,6 +3556,17 @@ impl Routines { // This means the local schema is already up to date and main loop doesn't need to do anything schema_version: INITIAL_SCHEMA_VERSION, }, + IndexDef { + table_id: Self::TABLE_ID, + id: 2, + name: "_pico_routine_owner_id".into(), + ty: IndexType::Tree, + opts: vec![IndexOption::Unique(false)], + parts: vec![Part::from(("owner", IndexFieldType::Unsigned)).is_nullable(false)], + operable: true, + // This means the local schema is already up to date and main loop doesn't need to do anything + schema_version: INITIAL_SCHEMA_VERSION, + }, ] } @@ -3535,6 +3609,14 @@ impl Routines { self.space.update(&[routine_id], ops)?; Ok(()) } + + pub fn by_owner_id( + &self, + owner_id: UserId, + ) -> tarantool::Result<EntryIter<RoutineDef, MP_SERDE>> { + let iter = self.index_owner_id.select(IteratorType::Eq, &[owner_id])?; + Ok(EntryIter::new(iter)) + } } impl ToEntryIter<MP_SERDE> for Routines { @@ -4443,14 +4525,17 @@ pub mod acl { Ok(()) } - /// Remove a user definition and any entities owned by it from the internal - /// clusterwide storage. + /// Remove a user with no dependent objects. pub fn global_drop_user( storage: &Clusterwide, user_id: UserId, initiator: UserId, - ) -> tarantool::Result<()> { - let user_def = storage.users.by_id(user_id)?.expect("failed to get user"); + ) -> traft::Result<()> { + let user_def = storage.users.by_id(user_id)?.ok_or_else(|| { + BoxError::new(TntErrorCode::NoSuchUser, format!("no such user #{user_id}")) + })?; + user_def.ensure_no_dependent_objects(storage)?; + storage.privileges.delete_all_by_grantee_id(user_id)?; storage.users.delete(user_id)?; @@ -4720,7 +4805,6 @@ pub mod acl { let lua = ::tarantool::lua_state(); lua.exec_with("box.schema.user.drop(...)", user_id) .map_err(LuaError::from)?; - Ok(()) } diff --git a/src/traft/op.rs b/src/traft/op.rs index 9b767f7d1f81491aa0a3da99d2f60acf75d026c5..569fe0f57a466c929dd3efa451899d02551aafb9 100644 --- a/src/traft/op.rs +++ b/src/traft/op.rs @@ -13,6 +13,7 @@ use ::tarantool::space::{Field, SpaceId}; use ::tarantool::tlua; use ::tarantool::tuple::{ToTupleBuffer, TupleBuffer}; use serde::{Deserialize, Serialize}; +use tarantool::error::{TarantoolError, TarantoolErrorCode}; use tarantool::index::IndexType; use tarantool::session::UserId; use tarantool::space::SpaceEngineType; @@ -203,7 +204,7 @@ impl std::fmt::Display for Op { initiator, schema_version, }) => { - write!(f, "DropUser({schema_version}, {user_id} {initiator})") + write!(f, "DropUser({schema_version}, {user_id}, {initiator})") } Self::Acl(Acl::CreateRole { role_def }) => { let UserDef { @@ -824,7 +825,7 @@ impl Acl { /// Performs preliminary checks on acl so that it will not fail when applied. /// These checks do not include authorization checks, which are done separately in /// [`crate::access_control::access_check_op`]. - pub fn validate(&self) -> Result<(), TRaftError> { + pub fn validate(&self, storage: &Clusterwide) -> Result<(), TRaftError> { // THOUGHT: should we move access_check_* fns here as it's done in tarantool? match self { Self::ChangeAuth { user_id, .. } => { @@ -840,6 +841,15 @@ impl Acl { if *user_id == GUEST_ID || *user_id == ADMIN_ID { return Err(TRaftError::other("dropping system user is not allowed")); } + + let user_def = storage.users.by_id(*user_id)?.ok_or_else(|| { + TarantoolError::new( + TarantoolErrorCode::NoSuchUser, + format!("no such user #{user_id}"), + ) + })?; + user_def.ensure_no_dependent_objects(storage)?; + // user_has_data will be successful in any case https://git.picodata.io/picodata/tarantool/-/blob/da5ad0fa3ab8940f524cfa9bf3d582347c01fc4a/src/box/alter.cc#L2846 // as box.schema.user.drop(..) deletes all the related spaces/priveleges/etc. } diff --git a/test/int/test_basics.py b/test/int/test_basics.py index ed4ba1a789e7a3f8f152a340423efae8580c4530..6872adcefa01588c8413c6f882f85ab059587af3 100644 --- a/test/int/test_basics.py +++ b/test/int/test_basics.py @@ -424,6 +424,7 @@ Insert(_pico_user, [3,"replication",0,null,1,"role"]))| Insert(_pico_table, [{_pico_table},"_pico_table",{{"Global":null}},[{{"field_type":"unsigned","is_nullable":false,"name":"id"}},{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"map","is_nullable":false,"name":"distribution"}},{{"field_type":"array","is_nullable":false,"name":"format"}},{{"field_type":"unsigned","is_nullable":false,"name":"schema_version"}},{{"field_type":"boolean","is_nullable":false,"name":"operable"}},{{"field_type":"string","is_nullable":false,"name":"engine"}},{{"field_type":"unsigned","is_nullable":false,"name":"owner"}},{{"field_type":"string","is_nullable":false,"name":"description"}}],0,true,"memtx",1,"Stores metadata of all the cluster tables in picodata."]), Insert(_pico_index, [{_pico_table},0,"_pico_table_id","tree",[{{"unique":true}}],[["id","unsigned",null,false,null]],true,0]), Insert(_pico_index, [{_pico_table},1,"_pico_table_name","tree",[{{"unique":true}}],[["name","string",null,false,null]],true,0]), +Insert(_pico_index, [{_pico_table},2,"_pico_table_owner_id","tree",[{{"unique":false}}],[["owner","unsigned",null,false,null]],true,0]), Insert(_pico_table, [{_pico_index},"_pico_index",{{"Global":null}},[{{"field_type":"unsigned","is_nullable":false,"name":"table_id"}},{{"field_type":"unsigned","is_nullable":false,"name":"id"}},{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"string","is_nullable":false,"name":"type"}},{{"field_type":"array","is_nullable":false,"name":"opts"}},{{"field_type":"array","is_nullable":false,"name":"parts"}},{{"field_type":"boolean","is_nullable":false,"name":"operable"}},{{"field_type":"unsigned","is_nullable":false,"name":"schema_version"}}],0,true,"memtx",1,""]), Insert(_pico_index, [{_pico_index},0,"_pico_index_id","tree",[{{"unique":true}}],[["table_id","unsigned",null,false,null],["id","unsigned",null,false,null]],true,0]), Insert(_pico_index, [{_pico_index},1,"_pico_index_name","tree",[{{"unique":true}}],[["name","string",null,false,null]],true,0]), @@ -441,6 +442,7 @@ Insert(_pico_index, [{_pico_replicaset},1,"_pico_replicaset_uuid","tree",[{{"uni Insert(_pico_table, [{_pico_user},"_pico_user",{{"Global":null}},[{{"field_type":"unsigned","is_nullable":false,"name":"id"}},{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"unsigned","is_nullable":false,"name":"schema_version"}},{{"field_type":"array","is_nullable":true,"name":"auth"}},{{"field_type":"unsigned","is_nullable":false,"name":"owner"}},{{"field_type":"string","is_nullable":false,"name":"type"}}],0,true,"memtx",1,""]), Insert(_pico_index, [{_pico_user},0,"_pico_user_id","tree",[{{"unique":true}}],[["id","unsigned",null,false,null]],true,0]), Insert(_pico_index, [{_pico_user},1,"_pico_user_name","tree",[{{"unique":true}}],[["name","string",null,false,null]],true,0]), +Insert(_pico_index, [{_pico_user},2,"_pico_user_owner_id","tree",[{{"unique":false}}],[["owner","unsigned",null,false,null]],true,0]), Insert(_pico_table, [{_pico_privilege},"_pico_privilege",{{"Global":null}},[{{"field_type":"unsigned","is_nullable":false,"name":"grantor_id"}},{{"field_type":"unsigned","is_nullable":false,"name":"grantee_id"}},{{"field_type":"string","is_nullable":false,"name":"privilege"}},{{"field_type":"string","is_nullable":false,"name":"object_type"}},{{"field_type":"integer","is_nullable":false,"name":"object_id"}},{{"field_type":"unsigned","is_nullable":false,"name":"schema_version"}}],0,true,"memtx",1,""]), Insert(_pico_index, [{_pico_privilege},0,"_pico_privilege_primary","tree",[{{"unique":true}}],[["grantee_id","unsigned",null,false,null],["object_type","string",null,false,null],["object_id","integer",null,false,null],["privilege","string",null,false,null]],true,0]), Insert(_pico_index, [{_pico_privilege},1,"_pico_privilege_object","tree",[{{"unique":false}}],[["object_type","string",null,false,null],["object_id","integer",null,false,null]],true,0]), @@ -449,6 +451,7 @@ Insert(_pico_index, [{_pico_tier},0,"_pico_tier_name","tree",[{{"unique":true}}] Insert(_pico_table, [{_pico_routine},"_pico_routine",{{"Global":null}},[{{"field_type":"unsigned","is_nullable":false,"name":"id"}},{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"string","is_nullable":false,"name":"kind"}},{{"field_type":"array","is_nullable":false,"name":"params"}},{{"field_type":"array","is_nullable":false,"name":"returns"}},{{"field_type":"string","is_nullable":false,"name":"language"}},{{"field_type":"string","is_nullable":false,"name":"body"}},{{"field_type":"string","is_nullable":false,"name":"security"}},{{"field_type":"boolean","is_nullable":false,"name":"operable"}},{{"field_type":"unsigned","is_nullable":false,"name":"schema_version"}},{{"field_type":"unsigned","is_nullable":false,"name":"owner"}}],0,true,"memtx",1,""]), Insert(_pico_index, [{_pico_routine},0,"_pico_routine_id","tree",[{{"unique":true}}],[["id","unsigned",null,false,null]],true,0]), Insert(_pico_index, [{_pico_routine},1,"_pico_routine_name","tree",[{{"unique":true}}],[["name","string",null,false,null]],true,0]), +Insert(_pico_index, [{_pico_routine},2,"_pico_routine_owner_id","tree",[{{"unique":false}}],[["owner","unsigned",null,false,null]],true,0]), Insert(_pico_table, [{_pico_plugin},"_pico_plugin",{{"Global":null}},[{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"boolean","is_nullable":false,"name":"enabled"}},{{"field_type":"array","is_nullable":false,"name":"services"}},{{"field_type":"string","is_nullable":false,"name":"version"}},{{"field_type":"string","is_nullable":false,"name":"description"}},{{"field_type":"array","is_nullable":false,"name":"migration_list"}}],0,true,"memtx",1,""]), Insert(_pico_index, [{_pico_plugin},0,"_pico_plugin_name","tree",[{{"unique":true}}],[["name","string",null,false,null],["version","string",null,false,null]],true,0]), Insert(_pico_table, [{_pico_service},"_pico_service",{{"Global":null}},[{{"field_type":"string","is_nullable":false,"name":"plugin_name"}},{{"field_type":"string","is_nullable":false,"name":"name"}},{{"field_type":"string","is_nullable":false,"name":"version"}},{{"field_type":"array","is_nullable":false,"name":"tiers"}},{{"field_type":"string","is_nullable":false,"name":"description"}}],0,true,"memtx",1,""]), diff --git a/test/int/test_sql.py b/test/int/test_sql.py index 0a6488006b5c1ef3c0194359baf1d4f1e2b95ce8..fb9403cc286c95fd34624bfb78a138c3805ada31 100644 --- a/test/int/test_sql.py +++ b/test/int/test_sql.py @@ -3950,6 +3950,113 @@ Alter access to user 'boba' is denied for user 'biba'\ assert boba in names_from_pico_user_table() +def test_drop_user(cluster: Cluster): + i1, _ = cluster.deploy(instance_count=2) + user = "user" + password = "Passw0rd" + + # Create and drop the created user + i1.sql(f""" create user "{user}" with password '{password}' """) + i1.sql(f""" drop user "{user}" """) + + # Create user with privileges + i1.sql(f""" create user "{user}" with password '{password}' """) + i1.sql(f""" grant create table to "{user}" """, sudo=True) + i1.sql(f""" grant create procedure to "{user}" """, sudo=True) + + # Drop user shouldn't fail despite the fact that the user has privileges + i1.sql(f""" drop user "{user}" """) + + # Create user with privileges + i1.sql(f""" create user "{user}" with password '{password}' """) + i1.sql(f""" grant create table to "{user}" """, sudo=True) + i1.sql(f""" grant create procedure to "{user}" """, sudo=True) + + # User creates a table + ddl = i1.sql( + """ + create table t (a text not null, b text not null, c text, primary key (a)) + using memtx + distributed by (a) + option (timeout = 3) + """, + user=user, + password=password, + ) + assert ddl["row_count"] == 1 + + # User creates a procedure + data = i1.sql( + """ + create procedure proc1(int) + language SQL + as $$insert into t values(?, ?, ?)$$ + """, + user=user, + password=password, + ) + assert data["row_count"] == 1 + + # Drop user should fail as the user owns tables and routines + with pytest.raises( + TarantoolError, + match=r"user cannot be dropped because some objects depend on it.*" + r"owner of tables t.*" + r"owner of procedures proc1.*", + ): + i1.sql(f""" drop user "{user}" """) + + # Drop user objects + data = i1.sql("drop table t") + assert data["row_count"] == 1 + + data = i1.sql("drop procedure proc1") + assert data["row_count"] == 1 + + # Grant create user and create role + data = i1.sql(f'grant create user to "{user}"', sudo=True) + assert data["row_count"] == 1 + + data = i1.sql(f'grant create role to "{user}"', sudo=True) + assert data["row_count"] == 1 + + # User creates user + data = i1.sql( + "create user lol with password 'Passw0rd'", + user=user, + password=password, + ) + assert data["row_count"] == 1 + + # User creates role + data = i1.sql( + "create role kek", + user=user, + password=password, + ) + assert data["row_count"] == 1 + + # Drop user should fail as the user owns another user and role + with pytest.raises( + TarantoolError, + match=r"user cannot be dropped because some objects depend on it.*" + r"owner of users lol.*" + r"owner of roles kek.*", + ): + i1.sql(f""" drop user "{user}" """) + + # Drop user objects + data = i1.sql("drop user lol") + assert data["row_count"] == 1 + + data = i1.sql("drop role kek") + assert data["row_count"] == 1 + + # Drop user with no objects + data = i1.sql(f'drop user "{user}"') + assert data["row_count"] == 1 + + def test_index(cluster: Cluster): cluster.deploy(instance_count=1) i1 = cluster.instances[0]