From 9a1dbb8a6ee5a8e3d0b51c391b001104e29aa2a3 Mon Sep 17 00:00:00 2001
From: Kurdakov Alexander <kusancho12@gmail.com>
Date: Wed, 22 Nov 2023 16:05:41 +0300
Subject: [PATCH] feat: populate builtins roles and users

---
 src/bootstrap_entries.rs | 73 +++++++++++++++++++++++++++++++++++++
 src/schema.rs            | 78 ++++++++++++++++++++++++++++++++++++++++
 src/storage.rs           | 27 +++++++++++---
 test/int/test_acl.py     | 36 ++++++++++++++++++-
 test/int/test_basics.py  | 51 ++++++++++++++++++++------
 test/int/test_sql.py     | 19 ++++++----
 6 files changed, 262 insertions(+), 22 deletions(-)

diff --git a/src/bootstrap_entries.rs b/src/bootstrap_entries.rs
index 57db4adb87..56a81bcf69 100644
--- a/src/bootstrap_entries.rs
+++ b/src/bootstrap_entries.rs
@@ -1,8 +1,15 @@
 use ::raft::prelude as raft;
 use protobuf::Message;
+use tarantool::auth::AuthData;
+use tarantool::auth::AuthDef;
+use tarantool::auth::AuthMethod;
+use tarantool::session::UserId;
 
 use crate::cli::args;
 use crate::instance::Instance;
+use crate::schema::PrivilegeDef;
+use crate::schema::RoleDef;
+use crate::schema::UserDef;
 use crate::sql::pgproto;
 use crate::storage;
 use crate::storage::ClusterwideTable;
@@ -12,6 +19,11 @@ use crate::traft;
 use crate::traft::op;
 use crate::traft::LogicalClock;
 
+pub const GUEST_ID: UserId = 0;
+pub const ADMIN_ID: UserId = 1;
+pub const PUBLIC_ID: UserId = 2;
+pub const SUPER_ID: UserId = 31;
+
 pub(super) fn prepare(args: &args::Run, instance: &Instance, tiers: &[Tier]) -> Vec<raft::Entry> {
     let mut lc = LogicalClock::new(instance.raft_id, 0);
     let mut init_entries = Vec::new();
@@ -108,6 +120,67 @@ pub(super) fn prepare(args: &args::Run, instance: &Instance, tiers: &[Tier]) ->
             &(PropertyName::SnapshotReadViewCloseTimeout, storage::DEFAULT_SNAPSHOT_READ_VIEW_CLOSE_TIMEOUT),
         )
     );
+    // Populate system roles and their privileges to match tarantool ones
+    // Note: op::Dml is used instead of op::Acl because with Acl
+    // replicas will attempt to apply these records to coresponding
+    // tarantool spaces which is not needed
+    // equivalent SQL expression: CREATE USER 'guest' WITH PASSWORD '' USING chap-sha1
+    init_entries_push_op(op::Dml::insert(
+        ClusterwideTable::User,
+        &UserDef {
+            id: GUEST_ID,
+            name: String::from("guest"),
+            schema_version: 0,
+            auth: AuthDef::new(
+                AuthMethod::ChapSha1,
+                AuthData::new(&AuthMethod::ChapSha1, "guest", "").into_string(),
+            ),
+        },
+    ));
+
+    // equivalent SQL expression: CREATE USER 'admin' with PASSWORD 'no password, see below for more details' USING chap-sha1
+    init_entries_push_op(op::Dml::insert(
+        ClusterwideTable::User,
+        &UserDef {
+            id: ADMIN_ID,
+            name: String::from("admin"),
+            schema_version: 0,
+            // this is a bit different from vanilla tnt
+            // in vanilla tnt auth def is empty. Here for simplicity give navailable module api
+            // we use ChapSha with invalid password
+            // (its impossible to get empty string as output of sha1)
+            auth: AuthDef::new(AuthMethod::ChapSha1, String::from("")),
+        },
+    ));
+
+    // equivalent SQL expression: CREATE ROLE 'public'
+    init_entries_push_op(op::Dml::insert(
+        ClusterwideTable::Role,
+        &RoleDef {
+            id: PUBLIC_ID,
+            name: String::from("public"),
+            schema_version: 0,
+        },
+    ));
+
+    // equivalent SQL expression: CREATE ROLE 'super'
+    init_entries_push_op(op::Dml::insert(
+        ClusterwideTable::Role,
+        &RoleDef {
+            id: SUPER_ID,
+            name: String::from("super"),
+            schema_version: 0,
+        },
+    ));
+
+    // equivalent SQL expressions under 'admin' user:
+    // GRANT <'usage', 'session'> ON 'universe' TO 'guest'
+    // GRANT 'execute' ON <'public', 'super'> TO 'guest'
+    // GRANT 'all privileges' ON 'universe' TO 'admin'
+    // GRANT 'all privileges' ON 'universe' TO 'super'
+    for priv_def in PrivilegeDef::get_default_privileges() {
+        init_entries_push_op(op::Dml::insert(ClusterwideTable::Privilege, priv_def));
+    }
 
     init_entries.push({
         let conf_change = raft::ConfChange {
diff --git a/src/schema.rs b/src/schema.rs
index c71bf24654..674acf4f71 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -1,3 +1,4 @@
+use once_cell::sync::OnceCell;
 use std::borrow::Cow;
 use std::collections::{BTreeMap, HashSet};
 use std::time::Duration;
@@ -23,6 +24,7 @@ use tarantool::{
 
 use serde::{Deserialize, Serialize};
 
+use crate::bootstrap_entries::{ADMIN_ID, GUEST_ID, SUPER_ID};
 use crate::cas::{self, compare_and_swap};
 use crate::storage::SPACE_ID_INTERNAL_MAX;
 use crate::storage::{ClusterwideTable, PropertyName};
@@ -369,6 +371,82 @@ impl PrivilegeDef {
             schema_version: 337,
         }
     }
+
+    pub fn get_default_privileges() -> &'static Vec<PrivilegeDef> {
+        static DEFAULT_PRIVILEGES: OnceCell<Vec<PrivilegeDef>> = OnceCell::new();
+        DEFAULT_PRIVILEGES.get_or_init(|| {
+            let mut v = Vec::new();
+
+            // SQL: GRANT <'usage', 'session'> ON 'universe' TO 'guest'
+            for privilege in ["usage", "session"] {
+                v.push(PrivilegeDef {
+                    grantor_id: ADMIN_ID,
+                    grantee_id: GUEST_ID,
+                    object_type: String::from("universe"),
+                    object_name: String::from(""),
+                    privilege: String::from(privilege),
+                    schema_version: 0,
+                });
+            }
+
+            // execute public
+            // execute on super (temporary until we switch to service account)
+            // SQL: GRANT 'execute' ON <'public', 'user'> TO 'guest'
+            for role in ["public", "super"] {
+                v.push(PrivilegeDef {
+                    grantor_id: ADMIN_ID,
+                    grantee_id: GUEST_ID,
+                    object_type: String::from("role"),
+                    object_name: String::from(role),
+                    privilege: String::from("execute"),
+                    schema_version: 0,
+                });
+            }
+
+            #[rustfmt::skip]
+            // TODO enum for privs implemented in picodata
+            let all_privileges = [
+                "read",
+                "write",
+                "execute",
+                "session",
+                "usage",
+                "create",
+                "drop",
+                "alter",
+                "grant",
+                "revoke",
+            ];
+
+            // admin - all on universe
+            // SQL: GRANT 'all privileges' ON 'universe' TO 'admin'
+            for privilege in all_privileges {
+                v.push(PrivilegeDef {
+                    grantor_id: ADMIN_ID,
+                    grantee_id: ADMIN_ID,
+                    object_type: String::from("universe"),
+                    object_name: String::from(""),
+                    privilege: String::from(privilege),
+                    schema_version: 0,
+                });
+            }
+
+            // super - all on universe
+            // GRANT 'all privileges' ON 'universe' TO 'super'
+            for privilege in all_privileges {
+                v.push(PrivilegeDef {
+                    grantor_id: ADMIN_ID,
+                    grantee_id: SUPER_ID,
+                    object_type: String::from("universe"),
+                    object_name: String::from(""),
+                    privilege: String::from(privilege),
+                    schema_version: 0,
+                });
+            }
+
+            v
+        })
+    }
 }
 
 ////////////////////////////////////////////////////////////////////////////////
diff --git a/src/storage.rs b/src/storage.rs
index d719fdff22..9b5c0fde52 100644
--- a/src/storage.rs
+++ b/src/storage.rs
@@ -2483,6 +2483,21 @@ trait SchemaDef {
     fn on_delete(key: &Self::Key, storage: &Clusterwide) -> traft::Result<()>;
 }
 
+/// used in `on_insert` of SchemaDef for `RoleDef` and `UserDef` to ignore errors when
+/// inserting builtin tarantool users and roles
+fn ignore_tuple_found_error(res: tarantool::Result<()>) -> traft::Result<()> {
+    if let Err(err) = res {
+        if let TntError::Tarantool(tnt_err) = &err {
+            if tnt_err.error_code() == tarantool::error::TarantoolErrorCode::TupleFound as u32 {
+                return Ok(());
+            }
+        }
+        return Err(traft::error::Error::from(err));
+    }
+
+    Ok(())
+}
+
 impl SchemaDef for TableDef {
     type Key = SpaceId;
 
@@ -2540,8 +2555,8 @@ impl SchemaDef for UserDef {
     #[inline(always)]
     fn on_insert(&self, storage: &Clusterwide) -> traft::Result<()> {
         _ = storage;
-        acl::on_master_create_user(self)?;
-        Ok(())
+        let res = acl::on_master_create_user(self);
+        ignore_tuple_found_error(res)
     }
 
     #[inline(always)]
@@ -2568,8 +2583,8 @@ impl SchemaDef for RoleDef {
     #[inline(always)]
     fn on_insert(&self, storage: &Clusterwide) -> traft::Result<()> {
         _ = storage;
-        acl::on_master_create_role(self)?;
-        Ok(())
+        let res = acl::on_master_create_role(self);
+        ignore_tuple_found_error(res)
     }
 
     #[inline(always)]
@@ -2596,6 +2611,10 @@ impl SchemaDef for PrivilegeDef {
     #[inline(always)]
     fn on_insert(&self, storage: &Clusterwide) -> traft::Result<()> {
         _ = storage;
+        if Self::get_default_privileges().contains(self) {
+            return Ok(());
+        }
+
         acl::on_master_grant_privilege(self)?;
         Ok(())
     }
diff --git a/test/int/test_acl.py b/test/int/test_acl.py
index 5301a8573a..4ab169a52b 100644
--- a/test/int/test_acl.py
+++ b/test/int/test_acl.py
@@ -828,6 +828,7 @@ def test_acl_from_snapshot(cluster: Cluster):
 
 def test_acl_drop_space_with_privileges(cluster: Cluster):
     i1, *_ = cluster.deploy(instance_count=1)
+    number_of_privileges_since_bootstrap = 24
 
     # Check that we can drop a table with privileges granted on it.
     index = i1.call("pico.create_user", "Dave", VALID_PASSWORD)
@@ -845,7 +846,40 @@ def test_acl_drop_space_with_privileges(cluster: Cluster):
 
     # Check that the picodata privileges are gone.
     privs = i1.call("box.execute", """ select count(*) from "_pico_privilege" """)
-    assert privs["rows"][0][0] == 0
+    assert privs["rows"][0][0] == number_of_privileges_since_bootstrap
+
+
+def test_builtin_users_and_roles(cluster: Cluster):
+    i1, *_ = cluster.deploy(instance_count=1)
+
+    # validate that builtin users and roles can be referenced in pico.{grant,revoke}_privilege
+    for user in ["admin", "guest", "public", "super"]:
+        i1.call(
+            "pico.grant_privilege",
+            user,
+            "read",
+            "table",
+            "_pico_property",
+        )
+
+    index = i1.call("pico.raft_get_index")
+    new_index = i1.call(
+        "pico.grant_privilege",
+        "admin",
+        "write",
+        "universe",
+    )
+
+    assert index == new_index
+
+    index = i1.call("pico.create_user", "Dave", VALID_PASSWORD)
+    new_index = i1.call(
+        "pico.grant_privilege",
+        "Dave",
+        "execute",
+        "role",
+        "super",
+    )
 
 
 # TODO: test acl get denied when there's an unfinished ddl
diff --git a/test/int/test_basics.py b/test/int/test_basics.py
index ed9e6dc699..8f311c536c 100644
--- a/test/int/test_basics.py
+++ b/test/int/test_basics.py
@@ -251,16 +251,44 @@ def test_raft_log(instance: Instance):
 |  9  | 1  |1.0.9|Insert({_pico_property}, ["max_pg_portals",50])|
 | 10  | 1  |1.0.10|Insert({_pico_property}, ["snapshot_chunk_max_size",16777216])|
 | 11  | 1  |1.0.11|Insert({_pico_property}, ["snapshot_read_view_close_timeout",86400.0])|
-| 12  | 1  |     |AddNode(1)|
-| 13  | 2  |     |-|
-| 14  | 2  |1.1.1|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Offline",0],["Online",1],{b},"default"])|
-| 15  | 2  |1.1.2|Insert({_pico_replicaset}, ["r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07","i1","default",0.0,"auto","not-ready"])|
-| 16  | 2  |1.1.3|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Replicated",1],["Online",1],{b},"default"])|
-| 17  | 2  |1.1.4|Update({_pico_replicaset}, ["r1"], [["=","weight",1.0], ["=","state","ready"]])|
-| 18  | 2  |1.1.5|Replace({_pico_property}, ["target_vshard_config",[{{"e0df68c5-e7f9-395f-86b3-30ad9e1b7b07":[{{"68d4a766-4144-3248-aeb4-e212356716e4":["guest:@127.0.0.1:{p}","i1",true]}},1.0]}},"on"]])|
-| 19  | 2  |1.1.6|Replace({_pico_property}, ["current_vshard_config",[{{"e0df68c5-e7f9-395f-86b3-30ad9e1b7b07":[{{"68d4a766-4144-3248-aeb4-e212356716e4":["guest:@127.0.0.1:{p}","i1",true]}},1.0]}},"on"]])|
-| 20  | 2  |1.1.7|Replace({_pico_property}, ["vshard_bootstrapped",true])|
-| 21  | 2  |1.1.8|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Online",1],["Online",1],{b},"default"])|
+| 12  | 1  |1.0.12|Insert({_pico_user}, [0,"guest",0,["chap-sha1","vhvewKp0tNyweZQ+cFKAlsyphfg="]])|
+| 13  | 1  |1.0.13|Insert({_pico_user}, [1,"admin",0,["chap-sha1",""]])|
+| 14  | 1  |1.0.14|Insert({_pico_role}, [2,"public",0])|
+| 15  | 1  |1.0.15|Insert({_pico_role}, [31,"super",0])|
+| 16  | 1  |1.0.16|Insert({_pico_privilege}, [1,0,"universe","","usage",0])|
+| 17  | 1  |1.0.17|Insert({_pico_privilege}, [1,0,"universe","","session",0])|
+| 18  | 1  |1.0.18|Insert({_pico_privilege}, [1,0,"role","public","execute",0])|
+| 19  | 1  |1.0.19|Insert({_pico_privilege}, [1,0,"role","super","execute",0])|
+| 20  | 1  |1.0.20|Insert({_pico_privilege}, [1,1,"universe","","read",0])|
+| 21  | 1  |1.0.21|Insert({_pico_privilege}, [1,1,"universe","","write",0])|
+| 22  | 1  |1.0.22|Insert({_pico_privilege}, [1,1,"universe","","execute",0])|
+| 23  | 1  |1.0.23|Insert({_pico_privilege}, [1,1,"universe","","session",0])|
+| 24  | 1  |1.0.24|Insert({_pico_privilege}, [1,1,"universe","","usage",0])|
+| 25  | 1  |1.0.25|Insert({_pico_privilege}, [1,1,"universe","","create",0])|
+| 26  | 1  |1.0.26|Insert({_pico_privilege}, [1,1,"universe","","drop",0])|
+| 27  | 1  |1.0.27|Insert({_pico_privilege}, [1,1,"universe","","alter",0])|
+| 28  | 1  |1.0.28|Insert({_pico_privilege}, [1,1,"universe","","grant",0])|
+| 29  | 1  |1.0.29|Insert({_pico_privilege}, [1,1,"universe","","revoke",0])|
+| 30  | 1  |1.0.30|Insert({_pico_privilege}, [1,31,"universe","","read",0])|
+| 31  | 1  |1.0.31|Insert({_pico_privilege}, [1,31,"universe","","write",0])|
+| 32  | 1  |1.0.32|Insert({_pico_privilege}, [1,31,"universe","","execute",0])|
+| 33  | 1  |1.0.33|Insert({_pico_privilege}, [1,31,"universe","","session",0])|
+| 34  | 1  |1.0.34|Insert({_pico_privilege}, [1,31,"universe","","usage",0])|
+| 35  | 1  |1.0.35|Insert({_pico_privilege}, [1,31,"universe","","create",0])|
+| 36  | 1  |1.0.36|Insert({_pico_privilege}, [1,31,"universe","","drop",0])|
+| 37  | 1  |1.0.37|Insert({_pico_privilege}, [1,31,"universe","","alter",0])|
+| 38  | 1  |1.0.38|Insert({_pico_privilege}, [1,31,"universe","","grant",0])|
+| 39  | 1  |1.0.39|Insert({_pico_privilege}, [1,31,"universe","","revoke",0])|
+| 40  | 1  |     |AddNode(1)|
+| 41  | 2  |     |-|
+| 42  | 2  |1.1.1|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Offline",0],["Online",1],{b},"default"])|
+| 43  | 2  |1.1.2|Insert({_pico_replicaset}, ["r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07","i1","default",0.0,"auto","not-ready"])|
+| 44  | 2  |1.1.3|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Replicated",1],["Online",1],{b},"default"])|
+| 45  | 2  |1.1.4|Update({_pico_replicaset}, ["r1"], [["=","weight",1.0], ["=","state","ready"]])|
+| 46  | 2  |1.1.5|Replace({_pico_property}, ["target_vshard_config",[{{"e0df68c5-e7f9-395f-86b3-30ad9e1b7b07":[{{"68d4a766-4144-3248-aeb4-e212356716e4":["guest:@127.0.0.1:{p}","i1",true]}},1.0]}},"on"]])|
+| 47  | 2  |1.1.6|Replace({_pico_property}, ["current_vshard_config",[{{"e0df68c5-e7f9-395f-86b3-30ad9e1b7b07":[{{"68d4a766-4144-3248-aeb4-e212356716e4":["guest:@127.0.0.1:{p}","i1",true]}},1.0]}},"on"]])|
+| 48  | 2  |1.1.7|Replace({_pico_property}, ["vshard_bootstrapped",true])|
+| 49  | 2  |1.1.8|Replace({_pico_instance}, ["i1","68d4a766-4144-3248-aeb4-e212356716e4",1,"r1","e0df68c5-e7f9-395f-86b3-30ad9e1b7b07",["Online",1],["Online",1],{b},"default"])|
 +-----+----+-----+--------+
 """.format(  # noqa: E501
         p=instance.port,
@@ -270,6 +298,9 @@ def test_raft_log(instance: Instance):
         _pico_replicaset=space_id("_pico_replicaset"),
         _pico_instance=space_id("_pico_instance"),
         _pico_tier=space_id("_pico_tier"),
+        _pico_privilege=space_id("_pico_privilege"),
+        _pico_user=space_id("_pico_user"),
+        _pico_role=space_id("_pico_role"),
     )
     assert strip_spaces(expected) == strip_spaces(raft_log)
 
diff --git a/test/int/test_sql.py b/test/int/test_sql.py
index 660e426c6f..0354069039 100644
--- a/test/int/test_sql.py
+++ b/test/int/test_sql.py
@@ -568,6 +568,11 @@ def test_sql_acl_user(cluster: Cluster):
     upper_username = "USER"
     rolename = "Role"
     upper_rolename = "ROLE"
+    default_users = [
+        [0, "guest", 0, ["chap-sha1", "vhvewKp0tNyweZQ+cFKAlsyphfg="]],
+        [1, "admin", 0, ["chap-sha1", ""]],
+    ]
+    default_roles = [[2, "public", 0], [31, "super", 0]]
     acl = i1.sql(
         f"""
         create user "{username}" with password '{password}'
@@ -583,7 +588,7 @@ def test_sql_acl_user(cluster: Cluster):
     # Dropping user that does exist should return 1.
     acl = i1.sql(f'drop user "{username}"')
     assert acl["row_count"] == 1
-    assert i1.call("box.space._pico_user:select") == []
+    assert i1.call("box.space._pico_user:select") == default_users
 
     # All the usernames below should match the same user.
     # * Upcasted username in double parentheses shouldn't change.
@@ -688,16 +693,16 @@ def test_sql_acl_user(cluster: Cluster):
     # Check altering works.
     acl = i1.sql(f"create user {username} with password '{password}' using md5")
     assert acl["row_count"] == 1
-    users_auth_was = i1.call("box.space._pico_user:select")[0][3]
+    users_auth_was = i1.call("box.space._pico_user:select")[2][3]
     # * Password and method aren't changed -> update nothing.
     acl = i1.sql(f"alter user {username} with password '{password}' using md5")
     assert acl["row_count"] == 1
-    users_auth_became = i1.call("box.space._pico_user:select")[0][3]
+    users_auth_became = i1.call("box.space._pico_user:select")[2][3]
     assert users_auth_was == users_auth_became
     # * Password is changed -> update hash.
     acl = i1.sql(f"alter user {username} with password '{another_password}' using md5")
     assert acl["row_count"] == 1
-    users_auth_became = i1.call("box.space._pico_user:select")[0][3]
+    users_auth_became = i1.call("box.space._pico_user:select")[2][3]
     assert users_auth_was[0] == users_auth_became[0]
     assert users_auth_was[1] != users_auth_became[1]
     # * Password and method are changed -> update method and hash.
@@ -705,13 +710,13 @@ def test_sql_acl_user(cluster: Cluster):
         f"alter user {username} with password '{another_password}' using chap-sha1"
     )
     assert acl["row_count"] == 1
-    users_auth_became = i1.call("box.space._pico_user:select")[0][3]
+    users_auth_became = i1.call("box.space._pico_user:select")[2][3]
     assert users_auth_was[0] != users_auth_became[0]
     assert users_auth_was[1] != users_auth_became[1]
     # * LDAP should ignore password -> update method and hash.
     acl = i1.sql(f"alter user {username} with password '{another_password}' using ldap")
     assert acl["row_count"] == 1
-    users_auth_became = i1.call("box.space._pico_user:select")[0][3]
+    users_auth_became = i1.call("box.space._pico_user:select")[2][3]
     assert users_auth_was[0] != users_auth_became[0]
     assert users_auth_became[1] == ""
     acl = i1.sql(f"drop user {username}")
@@ -744,7 +749,7 @@ def test_sql_acl_user(cluster: Cluster):
     # Dropping role that does exist should return 1.
     acl = i1.sql(f'drop role "{rolename}"')
     assert acl["row_count"] == 1
-    assert i1.call("box.space._pico_role:select") == []
+    assert i1.call("box.space._pico_role:select") == default_roles
 
     # All the rolenames below should match the same role.
     acl = i1.sql(f'create role "{upper_rolename}"')
-- 
GitLab