From 563c78efa842b32141a4b5ea5cbed3ea4fa88a3a Mon Sep 17 00:00:00 2001 From: "maksim.konovalov" Date: Tue, 24 Dec 2024 11:25:05 +0300 Subject: [PATCH] box: added logic for working with Tarantool schema Implemented the `box.Schema()` method that returns a `Schema` object for schema-related operations --- CHANGELOG.md | 2 + box/box.go | 6 + box/example_test.go | 128 +++++++++- box/info.go | 16 +- box/request.go | 38 --- box/schema.go | 21 ++ box/schema_test.go | 25 ++ box/schema_user.go | 542 ++++++++++++++++++++++++++++++++++++++++ box/schema_user_test.go | 191 ++++++++++++++ box/session.go | 64 +++++ box/session_test.go | 26 ++ box/tarantool_test.go | 403 +++++++++++++++++++++++++++++ box/testdata/config.lua | 4 +- 13 files changed, 1416 insertions(+), 50 deletions(-) delete mode 100644 box/request.go create mode 100644 box/schema.go create mode 100644 box/schema_test.go create mode 100644 box/schema_user.go create mode 100644 box/schema_user_test.go create mode 100644 box/session.go create mode 100644 box/session_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f9766f..4c2e1598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. ### Added - Extend box with replication information (#427). +- Implemented all box.schema.user operations requests and sugar interface (#426). +- Implemented box.session.su request and sugar interface only for current session granting (#426). ### Changed diff --git a/box/box.go b/box/box.go index be7f288a..0886683c 100644 --- a/box/box.go +++ b/box/box.go @@ -17,6 +17,12 @@ func New(conn tarantool.Doer) *Box { } } +// Schema returns a new Schema instance, providing access to schema-related operations. +// It uses the connection from the Box instance to communicate with Tarantool. +func (b *Box) Schema() *Schema { + return NewSchema(b.conn) +} + // Info retrieves the current information of the Tarantool instance. // It calls the "box.info" function and parses the result into the Info structure. func (b *Box) Info() (Info, error) { diff --git a/box/example_test.go b/box/example_test.go index 46194976..5c6dbbd1 100644 --- a/box/example_test.go +++ b/box/example_test.go @@ -18,7 +18,7 @@ import ( "github.com/tarantool/go-tarantool/v2/box" ) -func Example() { +func ExampleBox_Info() { dialer := tarantool.NetDialer{ Address: "127.0.0.1:3013", User: "test", @@ -58,3 +58,129 @@ func Example() { fmt.Printf("Box info uuids are equal") fmt.Printf("Current box info: %+v\n", resp.Info) } + +func ExampleSchemaUser_Exists() { + dialer := tarantool.NetDialer{ + Address: "127.0.0.1:3013", + User: "test", + Password: "test", + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + client, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + cancel() + if err != nil { + log.Fatalf("Failed to connect: %s", err) + } + + // You can use UserExistsRequest type and call it directly. + fut := client.Do(box.NewUserExistsRequest("user")) + + resp := &box.UserExistsResponse{} + + err = fut.GetTyped(resp) + if err != nil { + log.Fatalf("Failed get box schema user exists with error: %s", err) + } + + // Or use simple User implementation. + b := box.New(client) + exists, err := b.Schema().User().Exists(ctx, "user") + if err != nil { + log.Fatalf("Failed get box schema user exists with error: %s", err) + } + + if exists != resp.Exists { + log.Fatalf("Box schema users exists are not equal") + } + + fmt.Printf("Box schema users exists are equal") + fmt.Printf("Current exists state: %+v\n", exists) +} + +func ExampleSchemaUser_Create() { + // Connect to Tarantool. + dialer := tarantool.NetDialer{ + Address: "127.0.0.1:3013", + User: "test", + Password: "test", + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + client, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + cancel() + if err != nil { + log.Fatalf("Failed to connect: %s", err) + } + + // Create SchemaUser. + schemaUser := box.NewSchemaUser(client) + + // Create a new user. + username := "new_user" + options := box.UserCreateOptions{ + IfNotExists: true, + Password: "secure_password", + } + err = schemaUser.Create(ctx, username, options) + if err != nil { + log.Fatalf("Failed to create user: %s", err) + } + + fmt.Printf("User '%s' created successfully\n", username) +} + +func ExampleSchemaUser_Drop() { + // Connect to Tarantool. + dialer := tarantool.NetDialer{ + Address: "127.0.0.1:3013", + User: "test", + Password: "test", + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + client, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + cancel() + if err != nil { + log.Fatalf("Failed to connect: %s", err) + } + + // Create SchemaUser. + schemaUser := box.NewSchemaUser(client) + + // Drop an existing user. + username := "new_user" + options := box.UserDropOptions{ + IfExists: true, + } + err = schemaUser.Drop(ctx, username, options) + if err != nil { + log.Fatalf("Failed to drop user: %s", err) + } + + fmt.Printf("User '%s' dropped successfully\n", username) +} + +func ExampleSchemaUser_Password() { + // Connect to Tarantool. + dialer := tarantool.NetDialer{ + Address: "127.0.0.1:3013", + User: "test", + Password: "test", + } + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + client, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + cancel() + if err != nil { + log.Fatalf("Failed to connect: %s", err) + } + + // Create SchemaUser. + schemaUser := box.NewSchemaUser(client) + + // Get the password hash. + password := "my-password" + passwordHash, err := schemaUser.Password(ctx, password) + if err != nil { + log.Fatalf("Failed to get password hash: %s", err) + } + + fmt.Printf("Password '%s' hash: %s\n", password, passwordHash) +} diff --git a/box/info.go b/box/info.go index aabfd65e..0c57e260 100644 --- a/box/info.go +++ b/box/info.go @@ -112,18 +112,14 @@ func (ir *InfoResponse) DecodeMsgpack(d *msgpack.Decoder) error { // InfoRequest represents a request to retrieve information about the Tarantool instance. // It implements the tarantool.Request interface. type InfoRequest struct { - baseRequest -} - -// Body method is used to serialize the request's body. -// It is part of the tarantool.Request interface implementation. -func (i InfoRequest) Body(res tarantool.SchemaResolver, enc *msgpack.Encoder) error { - return i.impl.Body(res, enc) + *tarantool.CallRequest // Underlying Tarantool call request. } // NewInfoRequest returns a new empty info request. func NewInfoRequest() InfoRequest { - req := InfoRequest{} - req.impl = newCall("box.info") - return req + callReq := tarantool.NewCallRequest("box.info") + + return InfoRequest{ + callReq, + } } diff --git a/box/request.go b/box/request.go deleted file mode 100644 index bf51a72f..00000000 --- a/box/request.go +++ /dev/null @@ -1,38 +0,0 @@ -package box - -import ( - "context" - "io" - - "github.com/tarantool/go-iproto" - "github.com/tarantool/go-tarantool/v2" -) - -type baseRequest struct { - impl *tarantool.CallRequest -} - -func newCall(method string) *tarantool.CallRequest { - return tarantool.NewCallRequest(method) -} - -// Type returns IPROTO type for request. -func (req baseRequest) Type() iproto.Type { - return req.impl.Type() -} - -// Ctx returns a context of request. -func (req baseRequest) Ctx() context.Context { - return req.impl.Ctx() -} - -// Async returns request expects a response. -func (req baseRequest) Async() bool { - return req.impl.Async() -} - -// Response creates a response for the baseRequest. -func (req baseRequest) Response(header tarantool.Header, - body io.Reader) (tarantool.Response, error) { - return req.impl.Response(header, body) -} diff --git a/box/schema.go b/box/schema.go new file mode 100644 index 00000000..52a297b0 --- /dev/null +++ b/box/schema.go @@ -0,0 +1,21 @@ +package box + +import "github.com/tarantool/go-tarantool/v2" + +// Schema represents the schema-related operations in Tarantool. +// It holds a connection to interact with the Tarantool instance. +type Schema struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +// NewSchema creates a new Schema instance with the provided Tarantool connection. +// It initializes a Schema object that can be used for schema-related operations +// such as managing users, tables, and other schema elements in the Tarantool instance. +func NewSchema(conn tarantool.Doer) *Schema { + return &Schema{conn: conn} // Pass the connection to the Schema. +} + +// User returns a new SchemaUser instance, allowing schema-related user operations. +func (s *Schema) User() *SchemaUser { + return NewSchemaUser(s.conn) +} diff --git a/box/schema_test.go b/box/schema_test.go new file mode 100644 index 00000000..a8362b2c --- /dev/null +++ b/box/schema_test.go @@ -0,0 +1,25 @@ +package box + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewSchema(t *testing.T) { + ctx := context.Background() + + // Create a schema instance with a nil connection. This should lead to a panic later. + b := NewSchema(nil) + + // Ensure the schema is not nil (which it shouldn't be), but this is not meaningful + // since we will panic when we call the schema methods with the nil connection. + require.NotNil(t, b) + require.NotNil(t, b.User()) + + require.Panics(t, func() { + _, _ = b.User().Info(ctx, "panic-on") + }) + +} diff --git a/box/schema_user.go b/box/schema_user.go new file mode 100644 index 00000000..2bc0d7e2 --- /dev/null +++ b/box/schema_user.go @@ -0,0 +1,542 @@ +package box + +import ( + "context" + "fmt" + "strings" + + "github.com/tarantool/go-tarantool/v2" + "github.com/vmihailenco/msgpack/v5" +) + +// SchemaUser provides methods to interact with schema-related user operations in Tarantool. +type SchemaUser struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +// NewSchemaUser creates a new SchemaUser instance with the provided Tarantool connection. +// It initializes a SchemaUser object, which provides methods to perform user-related +// schema operations (such as creating, modifying, or deleting users) in the Tarantool instance. +func NewSchemaUser(conn tarantool.Doer) *SchemaUser { + return &SchemaUser{conn: conn} +} + +// UserExistsRequest represents a request to check if a user exists in Tarantool. +type UserExistsRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// UserExistsResponse represents the response to a user existence check. +type UserExistsResponse struct { + Exists bool // True if the user exists, false otherwise. +} + +// DecodeMsgpack decodes the response from a Msgpack-encoded byte slice. +func (uer *UserExistsResponse) DecodeMsgpack(d *msgpack.Decoder) error { + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure that the response array contains exactly 1 element (the "Exists" field). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the boolean value indicating whether the user exists. + uer.Exists, err = d.DecodeBool() + + return err +} + +// NewUserExistsRequest creates a new request to check if a user exists. +func NewUserExistsRequest(username string) UserExistsRequest { + callReq := tarantool.NewCallRequest("box.schema.user.exists").Args([]interface{}{username}) + + return UserExistsRequest{ + callReq, + } +} + +// Exists checks if the specified user exists in Tarantool. +func (u *SchemaUser) Exists(ctx context.Context, username string) (bool, error) { + // Create a request and send it to Tarantool. + req := NewUserExistsRequest(username).Context(ctx) + resp := &UserExistsResponse{} + + // Execute the request and parse the response. + err := u.conn.Do(req).GetTyped(resp) + + return resp.Exists, err +} + +// UserCreateOptions represents options for creating a user in Tarantool. +type UserCreateOptions struct { + // IfNotExists - if true, prevents an error if the user already exists. + IfNotExists bool `msgpack:"if_not_exists"` + // Password for the new user. + Password string `msgpack:"password"` +} + +// UserCreateRequest represents a request to create a new user in Tarantool. +type UserCreateRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserCreateRequest creates a new request to create a user with specified options. +func NewUserCreateRequest(username string, options UserCreateOptions) UserCreateRequest { + callReq := tarantool.NewCallRequest("box.schema.user.create"). + Args([]interface{}{username, options}) + + return UserCreateRequest{ + callReq, + } +} + +// UserCreateResponse represents the response to a user creation request. +type UserCreateResponse struct{} + +// DecodeMsgpack decodes the response for a user creation request. +// In this case, the response does not contain any data. +func (uer *UserCreateResponse) DecodeMsgpack(_ *msgpack.Decoder) error { + return nil +} + +// Create creates a new user in Tarantool with the given username and options. +func (u *SchemaUser) Create(ctx context.Context, username string, options UserCreateOptions) error { + // Create a request and send it to Tarantool. + req := NewUserCreateRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserDropOptions represents options for dropping a user in Tarantool. +type UserDropOptions struct { + IfExists bool `msgpack:"if_exists"` // If true, prevents an error if the user does not exist. +} + +// UserDropRequest represents a request to drop a user from Tarantool. +type UserDropRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserDropRequest creates a new request to drop a user with specified options. +func NewUserDropRequest(username string, options UserDropOptions) UserDropRequest { + callReq := tarantool.NewCallRequest("box.schema.user.drop"). + Args([]interface{}{username, options}) + + return UserDropRequest{ + callReq, + } +} + +// UserDropResponse represents the response to a user drop request. +type UserDropResponse struct{} + +// Drop drops the specified user from Tarantool, with optional conditions. +func (u *SchemaUser) Drop(ctx context.Context, username string, options UserDropOptions) error { + // Create a request and send it to Tarantool. + req := NewUserDropRequest(username, options).Context(ctx) + resp := &UserCreateResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserPasswordRequest represents a request to retrieve a user's password from Tarantool. +type UserPasswordRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserPasswordRequest creates a new request to fetch the user's password. +// It takes the username and constructs the request to Tarantool. +func NewUserPasswordRequest(username string) UserPasswordRequest { + // Create a request to get the user's password. + callReq := tarantool.NewCallRequest("box.schema.user.password").Args([]interface{}{username}) + + return UserPasswordRequest{ + callReq, + } +} + +// UserPasswordResponse represents the response to the user password request. +// It contains the password hash. +type UserPasswordResponse struct { + Hash string // The password hash of the user. +} + +// DecodeMsgpack decodes the response from Tarantool in Msgpack format. +// It expects the response to be an array of length 1, containing the password hash string. +func (upr *UserPasswordResponse) DecodeMsgpack(d *msgpack.Decoder) error { + // Decode the array length. + arrayLen, err := d.DecodeArrayLen() + if err != nil { + return err + } + + // Ensure the array contains exactly 1 element (the password hash). + if arrayLen != 1 { + return fmt.Errorf("protocol violation; expected 1 array entry, got %d", arrayLen) + } + + // Decode the string containing the password hash. + upr.Hash, err = d.DecodeString() + + return err +} + +// Password sends a request to retrieve the user's password from Tarantool. +// It returns the password hash as a string or an error if the request fails. +// It works just like hash function. +func (u *SchemaUser) Password(ctx context.Context, password string) (string, error) { + // Create the request and send it to Tarantool. + req := NewUserPasswordRequest(password).Context(ctx) + resp := &UserPasswordResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + // Get the decoded response. + err := fut.GetTyped(resp) + if err != nil { + return "", err + } + + // Return the password hash. + return resp.Hash, nil +} + +// UserPasswdRequest represents a request to change a user's password in Tarantool. +type UserPasswdRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserPasswdRequest creates a new request to change a user's password in Tarantool. +func NewUserPasswdRequest(args ...string) (UserPasswdRequest, error) { + callReq := tarantool.NewCallRequest("box.schema.user.passwd") + + switch len(args) { + case 1: + callReq.Args([]interface{}{args[0]}) + case 2: + callReq.Args([]interface{}{args[0], args[1]}) + default: + return UserPasswdRequest{}, fmt.Errorf("len of fields must be 1 or 2, got %d", len(args)) + + } + + return UserPasswdRequest{callReq}, nil +} + +// UserPasswdResponse represents the response to a user passwd request. +type UserPasswdResponse struct{} + +// Passwd sends a request to set a password for a currently logged in or a specified user. +// A currently logged-in user can change their password using box.schema.user.passwd(password). +// An administrator can change the password of another user +// with box.schema.user.passwd(username, password). +func (u *SchemaUser) Passwd(ctx context.Context, args ...string) error { + req, err := NewUserPasswdRequest(args...) + if err != nil { + return err + } + + req.Context(ctx) + + resp := &UserPasswdResponse{} + + // Execute the request and handle the response. + fut := u.conn.Do(req) + + err = fut.GetTyped(resp) + if err != nil { + return err + } + + return nil +} + +// UserInfoRequest represents a request to get a user's info in Tarantool. +type UserInfoRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserInfoRequest creates a new request to get user privileges. +func NewUserInfoRequest(username string) UserInfoRequest { + callReq := tarantool.NewCallRequest("box.schema.user.info").Args([]interface{}{username}) + + return UserInfoRequest{ + callReq, + } +} + +// PrivilegeType is a struct based on privilege object types list +// https://www.tarantool.io/en/doc/latest/admin/access_control/#all-object-types-and-permissions +type PrivilegeType string + +const ( + // PrivilegeUniverse - privilege type based on universe. + // A database (box.schema) that contains database objects, including spaces, + // indexes, users, roles, sequences, and functions. + // Granting privileges to universe gives a user access to any object in the database. + PrivilegeUniverse PrivilegeType = "universe" + // PrivilegeTypeUser - privilege type based on user. + // A user identifies a person or program that interacts with a Tarantool instance. + PrivilegeTypeUser PrivilegeType = "user" + // PrivilegeRole - privilege type based on role. + // A role is a container for privileges that can be granted to users. + // Roles can also be assigned to other roles, creating a role hierarchy. + PrivilegeRole PrivilegeType = "role" + // PrivilegeSpace - privilege type based on space. + // Tarantool stores tuples in containers called spaces. + PrivilegeSpace PrivilegeType = "space" + // PrivilegeFunction - privilege type based on functions. + // This allows access control based on function access. + PrivilegeFunction PrivilegeType = "function" + // PrivilegeSequence - privilege type based on sequences. + // A sequence is a generator of ordered integer values. + PrivilegeSequence PrivilegeType = "sequence" + // PrivilegeLuaEval - privilege type based on executing arbitrary Lua code. + PrivilegeLuaEval PrivilegeType = "lua_eval" + // PrivilegeLuaCall - privilege type based on + // calling any global user-defined Lua function. + PrivilegeLuaCall PrivilegeType = "lua_call" + // PrivilegeSQL - privilege type based on + // executing an arbitrary SQL expression. + PrivilegeSQL PrivilegeType = "sql" +) + +// Permission is a struct based on permission tarantool object +// https://www.tarantool.io/en/doc/latest/admin/access_control/#permissions +type Permission string + +const ( + // PermissionRead allows reading data of the specified object. + // For example, this permission can be used to allow a user + // to select data from the specified space. + PermissionRead Permission = "read" + // PermissionWrite allows updating data of the specified object. + // For example, this permission can be used to allow + // a user to modify data in the specified space. + PermissionWrite Permission = "write" + // PermissionCreate allows creating objects of the specified type. + // For example, this permission can be used to allow a user to create new spaces. + // Note that this permission requires read and write access to certain system spaces. + PermissionCreate Permission = "create" + // PermissionAlter allows altering objects of the specified type. + // Note that this permission requires read and write access to certain system spaces. + PermissionAlter Permission = "alter" + // PermissionDrop allows dropping objects of the specified type. + // Note that this permission requires read and write access to certain system spaces. + PermissionDrop Permission = "drop" + // PermissionExecute for role, + // allows using the specified role. For other object types, allows calling a function. + // Can be used only for role, universe, function, lua_eval, lua_call, sql. + PermissionExecute Permission = "execute" + // PermissionSession allows a user to connect to an instance over IPROTO. + PermissionSession Permission = "session" + // PermissionUsage allows a user to use their privileges on database objects + // (for example, read, write, and alter spaces). + PermissionUsage Permission = "usage" +) + +// Privilege is a structure that is used to create new rights, +// as well as obtain information for rights. +type Privilege struct { + // Permissions is a list of privileges that apply to the privileges object type. + Permissions []Permission + // ObjectType - one of privilege object types (it might be space,function, etc.). + ObjectType PrivilegeType + // ObjectName - can be the name of a function or space, + // and can also be empty in case of universe access + ObjectName string +} + +// UserInfoResponse represents the response to a user info request. +type UserInfoResponse struct { + Privileges []Privilege +} + +// DecodeMsgpack decodes the response from Tarantool in Msgpack format. +func (uer *UserInfoResponse) DecodeMsgpack(d *msgpack.Decoder) error { + rawArr := make([][][3]string, 0) + + err := d.Decode(&rawArr) + if err != nil { + return err + } + + privileges := make([]Privilege, len(rawArr[0])) + + for i, rawPrivileges := range rawArr[0] { + strPerms := strings.Split(rawPrivileges[0], ",") + + perms := make([]Permission, len(strPerms)) + for j, strPerm := range strPerms { + perms[j] = Permission(strPerm) + } + + privileges[i] = Privilege{ + Permissions: perms, + ObjectType: PrivilegeType(rawPrivileges[1]), + ObjectName: rawPrivileges[2], + } + } + + uer.Privileges = privileges + + return nil +} + +// Info returns a list of user privileges according to the box.schema.user.info method call. +func (u *SchemaUser) Info(ctx context.Context, username string) ([]Privilege, error) { + req := NewUserInfoRequest(username).Context(ctx) + + resp := &UserInfoResponse{} + fut := u.conn.Do(req) + + err := fut.GetTyped(resp) + if err != nil { + return nil, err + } + + return resp.Privileges, nil +} + +// prepareGrantAndRevokeArgs prepares the arguments for granting or revoking user permissions. +// It accepts a username, a privilege, and options for either granting or revoking. +// The generic type T can be UserGrantOptions or UserRevokeOptions. +func prepareGrantAndRevokeArgs[T UserGrantOptions | UserRevokeOptions](username string, + privilege Privilege, opts T) []interface{} { + + args := []interface{}{username} // Initialize args slice with the username. + + switch privilege.ObjectType { + case PrivilegeUniverse: + // Preparing arguments for granting permissions at the universe level. + // box.schema.user.grant(username, permissions, 'universe'[, nil, {options}]) + strPerms := make([]string, len(privilege.Permissions)) + for i, perm := range privilege.Permissions { + strPerms[i] = string(perm) // Convert each Permission to a string. + } + + reqPerms := strings.Join(strPerms, ",") // Join permissions into a single string. + + // Append universe-specific arguments to args. + args = append(args, reqPerms, string(privilege.ObjectType), nil, opts) + case PrivilegeRole: + // Handling the case where the object type is a role name. + // Append role-specific arguments to args. + args = append(args, privilege.ObjectName, nil, nil, opts) + default: + // Preparing arguments for granting permissions on a specific object. + strPerms := make([]string, len(privilege.Permissions)) + for i, perm := range privilege.Permissions { + strPerms[i] = string(perm) // Convert each Permission to a string. + } + + reqPerms := strings.Join(strPerms, ",") // Join permissions into a single string. + // box.schema.user.grant(username, permissions, object-type, object-name[, {options}]) + // Example: box.schema.user.grant('testuser', 'read', 'space', 'writers') + args = append(args, reqPerms, string(privilege.ObjectType), privilege.ObjectName, opts) + } + + return args // Return the prepared arguments. +} + +// UserGrantOptions holds options for granting permissions to a user. +type UserGrantOptions struct { + Grantor string `msgpack:"grantor,omitempty"` // Optional grantor name. + IfNotExists bool `msgpack:"if_not_exists"` // Option to skip if the grant already exists. +} + +// UserGrantRequest wraps a Tarantool call request for granting user permissions. +type UserGrantRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewUserGrantRequest creates a new UserGrantRequest based on provided parameters. +func NewUserGrantRequest(username string, privilege Privilege, + opts UserGrantOptions) UserGrantRequest { + args := prepareGrantAndRevokeArgs[UserGrantOptions](username, privilege, opts) + + // Create a new call request for the box.schema.user.grant method with the given args. + callReq := tarantool.NewCallRequest("box.schema.user.grant").Args(args) + + return UserGrantRequest{callReq} // Return the UserGrantRequest. +} + +// UserGrantResponse represents the response from a user grant request. +type UserGrantResponse struct{} + +// Grant executes the user grant operation in Tarantool, returning an error if it fails. +func (u *SchemaUser) Grant(ctx context.Context, username string, privilege Privilege, + opts UserGrantOptions) error { + req := NewUserGrantRequest(username, privilege, opts).Context(ctx) + + resp := &UserGrantResponse{} // Initialize a response object. + fut := u.conn.Do(req) // Execute the request. + + err := fut.GetTyped(resp) // Get the typed response and check for errors. + if err != nil { + return err // Return any errors encountered. + } + + return nil // Return nil if the operation was successful. +} + +// UserRevokeOptions holds options for revoking permissions from a user. +type UserRevokeOptions struct { + IfExists bool `msgpack:"if_exists"` // Option to skip if the revoke does not exist. +} + +// UserRevokeRequest wraps a Tarantool call request for revoking user permissions. +type UserRevokeRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// UserRevokeResponse represents the response from a user revoke request. +type UserRevokeResponse struct{} + +// NewUserRevokeRequest creates a new UserRevokeRequest based on provided parameters. +func NewUserRevokeRequest(username string, privilege Privilege, + opts UserRevokeOptions) UserRevokeRequest { + args := prepareGrantAndRevokeArgs[UserRevokeOptions](username, privilege, opts) + + // Create a new call request for the box.schema.user.revoke method with the given args. + callReq := tarantool.NewCallRequest("box.schema.user.revoke").Args(args) + + return UserRevokeRequest{callReq} +} + +// Revoke executes the user revoke operation in Tarantool, returning an error if it fails. +func (u *SchemaUser) Revoke(ctx context.Context, username string, privilege Privilege, + opts UserRevokeOptions) error { + req := NewUserRevokeRequest(username, privilege, opts).Context(ctx) + + resp := &UserRevokeResponse{} // Initialize a response object. + fut := u.conn.Do(req) // Execute the request. + + err := fut.GetTyped(resp) // Get the typed response and check for errors. + if err != nil { + return err + } + + return nil +} diff --git a/box/schema_user_test.go b/box/schema_user_test.go new file mode 100644 index 00000000..d78f313d --- /dev/null +++ b/box/schema_user_test.go @@ -0,0 +1,191 @@ +package box + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tarantool/go-tarantool/v2" + "github.com/vmihailenco/msgpack/v5" + "github.com/vmihailenco/msgpack/v5/msgpcode" +) + +func TestNewSchemaUser(t *testing.T) { + // Create a schema user instance with a nil connection. This should lead to a panic later. + su := NewSchemaUser(nil) + + // Ensure the schema user is not nil (which it shouldn't be), but this is not meaningful + // since we will panic when we call some method with the nil connection. + require.NotNil(t, su) + + // We expect a panic because we are passing a nil connection (nil Doer) to the By function. + // The library does not control this zone, and the nil connection would cause a runtime error + // when we attempt to call methods (like Info) on it. + // This test ensures that such an invalid state is correctly handled by causing a panic, + // as it's outside the library's responsibility. + require.Panics(t, func() { + // Calling Exists on a schema user with a nil connection will result in a panic, + // since the underlying connection (Doer) cannot perform the requested action (it's nil). + _, _ = su.Exists(context.TODO(), "test") + }) +} + +func TestUserExistsResponse_DecodeMsgpack(t *testing.T) { + tCases := map[bool]func() *bytes.Buffer{ + true: func() *bytes.Buffer { + buf := bytes.NewBuffer(nil) + buf.WriteByte(msgpcode.FixedArrayLow | byte(1)) + buf.WriteByte(msgpcode.True) + + return buf + }, + false: func() *bytes.Buffer { + buf := bytes.NewBuffer(nil) + buf.WriteByte(msgpcode.FixedArrayLow | byte(1)) + buf.WriteByte(msgpcode.False) + + return buf + }, + } + + for tCaseBool, tCaseBuf := range tCases { + tCaseBool := tCaseBool + tCaseBuf := tCaseBuf() + + t.Run(fmt.Sprintf("case: %t", tCaseBool), func(t *testing.T) { + t.Parallel() + + resp := UserExistsResponse{} + + require.NoError(t, resp.DecodeMsgpack(msgpack.NewDecoder(tCaseBuf))) + require.Equal(t, tCaseBool, resp.Exists) + }) + } + +} + +func TestUserPasswordResponse_DecodeMsgpack(t *testing.T) { + tCases := []string{ + "test", + "$tr0ng_pass", + } + + for _, tCase := range tCases { + tCase := tCase + + t.Run(tCase, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) + buf.WriteByte(msgpcode.FixedArrayLow | byte(1)) + + bts, err := msgpack.Marshal(tCase) + require.NoError(t, err) + buf.Write(bts) + + resp := UserPasswordResponse{} + + err = resp.DecodeMsgpack(msgpack.NewDecoder(buf)) + require.NoError(t, err) + require.Equal(t, tCase, resp.Hash) + }) + } + +} + +func FuzzUserPasswordResponse_DecodeMsgpack(f *testing.F) { + f.Fuzz(func(t *testing.T, orig string) { + buf := bytes.NewBuffer(nil) + buf.WriteByte(msgpcode.FixedArrayLow | byte(1)) + + bts, err := msgpack.Marshal(orig) + require.NoError(t, err) + buf.Write(bts) + + resp := UserPasswordResponse{} + + err = resp.DecodeMsgpack(msgpack.NewDecoder(buf)) + require.NoError(t, err) + require.Equal(t, orig, resp.Hash) + }) +} + +func TestNewUserExistsRequest(t *testing.T) { + t.Parallel() + + req := UserExistsRequest{} + + require.NotPanics(t, func() { + req = NewUserExistsRequest("test") + }) + + require.Implements(t, (*tarantool.Request)(nil), req) +} + +func TestNewUserCreateRequest(t *testing.T) { + t.Parallel() + + req := UserCreateRequest{} + + require.NotPanics(t, func() { + req = NewUserCreateRequest("test", UserCreateOptions{}) + }) + + require.Implements(t, (*tarantool.Request)(nil), req) +} + +func TestNewUserDropRequest(t *testing.T) { + t.Parallel() + + req := UserDropRequest{} + + require.NotPanics(t, func() { + req = NewUserDropRequest("test", UserDropOptions{}) + }) + + require.Implements(t, (*tarantool.Request)(nil), req) +} + +func TestNewUserPasswordRequest(t *testing.T) { + t.Parallel() + + req := UserPasswordRequest{} + + require.NotPanics(t, func() { + req = NewUserPasswordRequest("test") + }) + + require.Implements(t, (*tarantool.Request)(nil), req) +} + +func TestNewUserPasswdRequest(t *testing.T) { + t.Parallel() + + var err error + req := UserPasswdRequest{} + + require.NotPanics(t, func() { + req, err = NewUserPasswdRequest("test") + require.NoError(t, err) + }) + + _, err = NewUserPasswdRequest() + require.Errorf(t, err, "invalid arguments count") + + require.Implements(t, (*tarantool.Request)(nil), req) +} + +func TestNewUserInfoRequest(t *testing.T) { + t.Parallel() + + var err error + req := UserInfoRequest{} + + require.NotPanics(t, func() { + req = NewUserInfoRequest("test") + require.NoError(t, err) + }) + + require.Implements(t, (*tarantool.Request)(nil), req) +} diff --git a/box/session.go b/box/session.go new file mode 100644 index 00000000..b781009d --- /dev/null +++ b/box/session.go @@ -0,0 +1,64 @@ +package box + +import ( + "context" + "fmt" + + "github.com/tarantool/go-tarantool/v2" +) + +// Session struct represents a connection session to Tarantool. +type Session struct { + conn tarantool.Doer // Connection interface for interacting with Tarantool. +} + +// NewSession creates a new Session instance, taking a Tarantool connection as an argument. +func NewSession(conn tarantool.Doer) *Session { + return &Session{conn: conn} // Pass the connection to the Session structure. +} + +// Session method returns a new Session object associated with the Box instance. +func (b *Box) Session() *Session { + return NewSession(b.conn) +} + +// SessionSuRequest struct wraps a Tarantool call request specifically for session switching. +type SessionSuRequest struct { + *tarantool.CallRequest // Underlying Tarantool call request. +} + +// NewSessionSuRequest creates a new SessionSuRequest for switching session to a specified username. +// It returns an error if any execute functions are provided, as they are not supported now. +func NewSessionSuRequest(username string, execute ...any) (SessionSuRequest, error) { + args := []interface{}{username} // Create args slice with the username. + + // Check if any execute functions were provided and return an error if so. + if len(execute) > 0 { + return SessionSuRequest{}, + fmt.Errorf("user functions call inside su command is unsupported now," + + " because Tarantool needs functions signature instead of name") + } + + // Create a new call request for the box.session.su method with the given args. + callReq := tarantool.NewCallRequest("box.session.su").Args(args) + + return SessionSuRequest{ + callReq, // Return the new SessionSuRequest containing the call request. + }, nil +} + +// Su method is used to switch the session to the specified username. +// It sends the request to Tarantool and returns a future response or an error. +func (s *Session) Su(ctx context.Context, username string, + execute ...any) (*tarantool.Future, error) { + // Create a request and send it to Tarantool. + req, err := NewSessionSuRequest(username, execute...) + if err != nil { + return nil, err // Return any errors encountered while creating the request. + } + + req.Context(ctx) // Attach the context to the request for cancellation and timeout. + + // Execute the request and return the future response, or an error. + return s.conn.Do(req), nil +} diff --git a/box/session_test.go b/box/session_test.go new file mode 100644 index 00000000..6ed173fc --- /dev/null +++ b/box/session_test.go @@ -0,0 +1,26 @@ +package box + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBox_Session(t *testing.T) { + b := New(nil) + require.NotNil(t, b.Session()) +} + +func TestNewSession(t *testing.T) { + require.NotPanics(t, func() { + NewSession(nil) + }) +} + +func TestNewSessionSuRequest(t *testing.T) { + _, err := NewSessionSuRequest("admin", 1, 2, 3) + require.Error(t, err, "error should be occurred, because of tarantool signature requires") + + _, err = NewSessionSuRequest("admin") + require.NoError(t, err) +} diff --git a/box/tarantool_test.go b/box/tarantool_test.go index 515eac3d..6f0a2a02 100644 --- a/box/tarantool_test.go +++ b/box/tarantool_test.go @@ -2,6 +2,7 @@ package box_test import ( "context" + "errors" "log" "os" "testing" @@ -9,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/tarantool/go-iproto" "github.com/tarantool/go-tarantool/v2" "github.com/tarantool/go-tarantool/v2/box" "github.com/tarantool/go-tarantool/v2/test_helpers" @@ -67,6 +69,407 @@ func TestBox_Info(t *testing.T) { validateInfo(t, resp.Info) } +func TestBox_Sugar_Schema_UserCreate(t *testing.T) { + const ( + username = "exists" + password = "exists" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + // Create new user. + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + t.Run("can connect with new credentials", func(t *testing.T) { + t.Parallel() + // Check that password is valid and we can connect to tarantool with such credentials + var newUserDialer = tarantool.NetDialer{ + Address: server, + User: username, + Password: password, + } + + // We can connect with our new credentials + newUserConn, err := tarantool.Connect(ctx, newUserDialer, tarantool.Opts{}) + require.NoError(t, err) + require.NotNil(t, newUserConn) + require.NoError(t, newUserConn.Close()) + }) + t.Run("create user already exists error", func(t *testing.T) { + t.Parallel() + // Get error that user already exists + err := b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.Error(t, err) + + // Require that error code is ER_USER_EXISTS + var boxErr tarantool.Error + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_USER_EXISTS, boxErr.Code) + }) + + t.Run("exists method return true", func(t *testing.T) { + t.Parallel() + // Check that already exists by exists call procedure + exists, err := b.Schema().User().Exists(ctx, username) + require.True(t, exists) + require.NoError(t, err) + }) + + t.Run("no error if IfNotExists option is true", func(t *testing.T) { + t.Parallel() + + err := b.Schema().User().Create(ctx, username, box.UserCreateOptions{ + Password: password, + IfNotExists: true, + }) + + require.NoError(t, err) + }) +} + +func TestBox_Sugar_Schema_UserPassword(t *testing.T) { + const ( + password = "passwd" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + // Require password hash + hash, err := b.Schema().User().Password(ctx, password) + require.NoError(t, err) + require.NotEmpty(t, hash) +} + +func TestBox_Sugar_Schema_UserDrop(t *testing.T) { + const ( + username = "to_drop" + password = "to_drop" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + t.Run("drop user after create", func(t *testing.T) { + // Create new user + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + // Try to drop user + err = b.Schema().User().Drop(ctx, username, box.UserDropOptions{}) + require.NoError(t, err) + + t.Run("error double drop without IfExists option", func(t *testing.T) { + // Require error cause user already deleted + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", + box.UserDropOptions{}) + require.Error(t, err) + + var boxErr tarantool.Error + + // Require that error code is ER_NO_SUCH_USER + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) + }) + t.Run("ok double drop with IfExists option", func(t *testing.T) { + // Require no error with IfExists: true option + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", + box.UserDropOptions{IfExists: true}) + require.NoError(t, err) + }) + }) + + t.Run("drop not existing user", func(t *testing.T) { + t.Parallel() + // Require error cause user already deleted + err = b.Schema().User().Drop(ctx, "some_strange_not_existing_name", box.UserDropOptions{}) + require.Error(t, err) + + var boxErr tarantool.Error + + // Require that error code is ER_NO_SUCH_USER + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_NO_SUCH_USER, boxErr.Code) + }) +} + +func TestSchemaUser_Passwd(t *testing.T) { + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + t.Run("user not found", func(t *testing.T) { + err = b.Schema().User().Passwd(ctx, "not-exists", "new_password") + require.Error(t, err) + }) + + t.Run("new user change password and connect", func(t *testing.T) { + const ( + username = "new_password_user" + startPassword = "new_password" + endPassword = "end_password" + ) + + err := b.Schema().User().Create(ctx, username, + box.UserCreateOptions{Password: startPassword, IfNotExists: true}) + require.NoError(t, err) + + err = b.Schema().User().Passwd(ctx, username, endPassword) + require.NoError(t, err) + + dialer := dialer + dialer.User = username + dialer.Password = startPassword + + _, err = tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.Error(t, err, "can't connect with old password") + + dialer.Password = endPassword + _, err = tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err, "ok connection with new password") + }) + + t.Run("can't change self password without grants", func(t *testing.T) { + const ( + username = "new_password_user_fail_conn" + startPassword = "new_password" + endPassword = "end_password" + ) + + err := b.Schema().User().Create(ctx, username, + box.UserCreateOptions{Password: startPassword, IfNotExists: true}) + require.NoError(t, err) + + dialer := dialer + dialer.User = username + dialer.Password = startPassword + + conn2Fail, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + require.NotNil(t, conn2Fail) + + b := box.New(conn2Fail) + // can't change self user password without grants + err = b.Schema().User().Passwd(ctx, endPassword) + require.Error(t, err) + + // Require that error code is AccessDeniedError, + var boxErr tarantool.Error + errors.As(err, &boxErr) + require.Equal(t, iproto.ER_ACCESS_DENIED, boxErr.Code) + }) +} + +func TestSchemaUser_Info(t *testing.T) { + t.Parallel() + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + t.Run("test user privileges is correct", func(t *testing.T) { + privileges, err := b.Schema().User().Info(ctx, dialer.User) + require.NoError(t, err) + require.NotNil(t, privileges) + + require.Len(t, privileges, 4) + }) + + t.Run("privileges of non existing user", func(t *testing.T) { + privileges, err := b.Schema().User().Info(ctx, "non-existing") + require.Error(t, err) + require.Nil(t, privileges) + }) + +} + +func TestBox_Sugar_Schema_UserGrant(t *testing.T) { + const ( + username = "to_grant" + password = "to_grant" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + t.Run("can`t grant without su permissions", func(t *testing.T) { + err = b.Schema().User().Grant(ctx, username, box.Privilege{ + Permissions: []box.Permission{ + box.PermissionRead, + }, + ObjectType: box.PrivilegeSpace, + ObjectName: "space1", + }, box.UserGrantOptions{IfNotExists: false}) + require.Error(t, err) + }) + + t.Run("can grant with su admin permissions", func(t *testing.T) { + startPrivilages, err := b.Schema().User().Info(ctx, username) + require.NoError(t, err) + + future, err := b.Session().Su(ctx, "admin") + require.NoError(t, err) + + _, err = future.Get() + + require.NoError(t, err, "default user in super group") + + newPrivilege := box.Privilege{ + Permissions: []box.Permission{ + box.PermissionRead, + }, + ObjectType: box.PrivilegeSpace, + ObjectName: "space1", + } + + require.NotContains(t, startPrivilages, newPrivilege) + + err = b.Schema().User().Grant(ctx, + username, + newPrivilege, + box.UserGrantOptions{ + IfNotExists: false, + }) + require.NoError(t, err) + + endPrivileges, err := b.Schema().User().Info(ctx, username) + require.NoError(t, err) + require.NotEqual(t, startPrivilages, endPrivileges) + require.Contains(t, endPrivileges, newPrivilege) + }) +} + +func TestSchemaUser_Revoke(t *testing.T) { + const ( + username = "to_revoke" + password = "to_revoke" + ) + + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + err = b.Schema().User().Create(ctx, username, box.UserCreateOptions{Password: password}) + require.NoError(t, err) + + t.Run("can`t revoke without su permissions", func(t *testing.T) { + err = b.Schema().User().Grant(ctx, username, box.Privilege{ + Permissions: []box.Permission{ + box.PermissionRead, + }, + ObjectType: box.PrivilegeSpace, + ObjectName: "space1", + }, box.UserGrantOptions{IfNotExists: false}) + require.Error(t, err) + }) + + t.Run("can revoke with su admin permissions", func(t *testing.T) { + startPrivileges, err := b.Schema().User().Info(ctx, username) + require.NoError(t, err) + + future, err := b.Session().Su(ctx, "admin") + require.NoError(t, err) + + _, err = future.Get() + + require.NoError(t, err, "dialer user in super group") + + require.NotEmpty(t, startPrivileges) + examplePriv := startPrivileges[0] + + err = b.Schema().User().Revoke(ctx, + username, + examplePriv, + box.UserRevokeOptions{ + IfExists: false, + }) + + require.NoError(t, err) + + privileges, err := b.Schema().User().Info(ctx, username) + require.NoError(t, err) + + require.NotEqual(t, startPrivileges, privileges) + require.NotContains(t, privileges, examplePriv) + }) + + t.Run("try to revoke non existing permissions", func(t *testing.T) { + startPrivileges, err := b.Schema().User().Info(ctx, username) + require.NoError(t, err) + + future, err := b.Session().Su(ctx, "admin") + require.NoError(t, err) + _, err = future.Get() + + require.NoError(t, err, "dialer user in super group") + + require.NotEmpty(t, startPrivileges) + examplePriv := box.Privilege{ + Permissions: []box.Permission{box.PermissionRead}, + ObjectName: "non_existing_space", + ObjectType: box.PrivilegeSpace, + } + + err = b.Schema().User().Revoke(ctx, + username, + examplePriv, + box.UserRevokeOptions{ + IfExists: false, + }) + + require.Error(t, err) + }) +} + +func TestSession_Su(t *testing.T) { + ctx := context.TODO() + + conn, err := tarantool.Connect(ctx, dialer, tarantool.Opts{}) + require.NoError(t, err) + + b := box.New(conn) + + t.Run("su with call", func(t *testing.T) { + _, err := b.Session().Su(ctx, "admin", "echo", 1, 2) + require.Error(t, err, "unsupported now") + }) + + t.Run("su admin permissions", func(t *testing.T) { + _, err := b.Session().Su(ctx, "admin") + require.NoError(t, err) + }) +} + func runTestMain(m *testing.M) int { instance, err := test_helpers.StartTarantool(test_helpers.StartOpts{ Dialer: dialer, diff --git a/box/testdata/config.lua b/box/testdata/config.lua index 061439aa..3d0db6ac 100644 --- a/box/testdata/config.lua +++ b/box/testdata/config.lua @@ -4,8 +4,10 @@ box.cfg{ work_dir = os.getenv("TEST_TNT_WORK_DIR"), } +box.schema.space.create('space1') + box.schema.user.create('test', { password = 'test' , if_not_exists = true }) -box.schema.user.grant('test', 'execute', 'universe', nil, { if_not_exists = true }) +box.schema.user.grant('test', 'super', nil, nil, { if_not_exists = true }) -- Set listen only when every other thing is configured. box.cfg{