Changes
6 changed files (+757/-0)
-
-
@@ -111,6 +111,20 @@ IsAdmin: proto.Bool(false),PasswordLogin: nil, }) p.permissions[ev.UserCreated.GetId()] = make(map[types.Permission]struct{}) case *workspaceEventsv1.Event_UserUpdated: id := ev.UserUpdated.GetId() for _, user := range p.Projection.Users { if user.GetId() == id { if ev.UserUpdated.GetName() != "" { user.Name = ev.UserUpdated.Name } if ev.UserUpdated.GetDisplayName() != "" { user.DisplayName = ev.UserUpdated.DisplayName } } } case *workspaceEventsv1.Event_UserDeleted: id := ev.UserDeleted.GetId()
-
-
-
@@ -60,6 +60,22 @@ },} } func UpdateUser(id string, name string, displayName string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserUpdated{ UserUpdated: &workspaceEvent.UserUpdated{ Id: proto.String(id), Name: proto.String(name), DisplayName: proto.String(displayName), }, }, }, }, } } func DeleteUser(id string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{
-
@@ -98,6 +114,20 @@ Event: &eventV1.Event_WorkspaceEvent{WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminAccessGranted{ AdminAccessGranted: &workspaceEvent.AdminAccessGranted{ UserId: proto.String(userID), }, }, }, }, } } func RevokeAdminAccess(userID string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminAccessRevoked{ AdminAccessRevoked: &workspaceEvent.AdminAccessRevoked{ UserId: proto.String(userID), }, },
-
-
-
@@ -0,0 +1,307 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "strings" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func updateUserSystemError(message string) *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func updateUserAuthError() *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func updateUserMissingField(path string) *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String(path), }, }, }) } func updateUserPermError() *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func (s *Service) UpdateUser( ctx context.Context, req *connect.Request[workspaceV2.UpdateUserRequest], ) (*connect.Response[workspaceV2.UpdateUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "UpdateUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return updateUserAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return updateUserSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return updateUserSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return updateUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return updateUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return updateUserSystemError("Database error"), nil } loginUser, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return updateUserAuthError(), nil } if req.Msg.Id == nil { return updateUserMissingField("id"), nil } id := req.Msg.Id.GetValue() if id == "" { return updateUserMissingField("id.value"), nil } updateFields := req.Msg.UpdateFields if len(updateFields) == 0 { updateFields = []int32{4, 5, 6, 7} } name := "" if slices.Contains(updateFields, 4) { name = req.Msg.GetName() if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } for _, u := range users.Projection.Users { if u.GetId() != id && u.GetName() == name { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_DuplicatedName{ DuplicatedName: name, }, }), nil } } } displayName := "" if slices.Contains(updateFields, 5) { displayName = strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") } for _, u := range users.Projection.Users { if u.GetId() == id { requiredPerm := types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE if u.GetIsAdmin() { requiredPerm = types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE } if id == loginUser.GetId() { requiredPerm = types.Permission_PERMISSION_UPDATE_SELF_PROFILE } if !slices.Contains(loginUser.Permissions, requiredPerm) { return updateUserPermError(), nil } events := make([]*eventV1.Event, 0, 3) if name != "" || displayName != "" { events = append(events, workspaceEvent.UpdateUser(id, name, displayName)) } if slices.Contains(updateFields, 6) { if req.Msg.GetIsAdmin() { if !slices.Contains(loginUser.Permissions, types.Permission_PERMISSION_ADD_ADMIN_USER) { return updateUserPermError(), nil } if !u.GetIsAdmin() { events = append(events, workspaceEvent.GrantAdminAccess(id)) } } else { if u.GetIsAdmin() { if workspace.Projection.GetNumberOfAdmins() == 1 { logger.Warn("Attempt to remove admin role from only admin in the workspace", "userID", id) return updateUserSystemError("This operation results in no admin. Aborted."), nil } events = append(events, workspaceEvent.RevokeAdminAccess(id)) } } } if slices.Contains(updateFields, 7) { permissionUpdateFields := req.Msg.PermissionUpdateFields if len(permissionUpdateFields) == 0 { permissionUpdateFields = []int32{ 1, 2, 3, 4, 5, 6, 7, } } perms := req.Msg.Permissions if perms == nil { perms = &workspaceV2.UserPermissions{} } permissionsToAdd := make([]types.Permission, 0) permissionsToRemove := make([]types.Permission, 0) for _, num := range permissionUpdateFields { switch num { case 1: if perms.GetCanAddUser() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_ADD_REGULAR_USER) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_ADD_REGULAR_USER) } case 2: if perms.GetCanDeleteRegularUser() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_DELETE_REGULAR_USER) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_DELETE_REGULAR_USER) } case 3: if perms.GetCanReadOtherUserProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE) } case 4: if perms.GetCanUpdateOtherRegularUserProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) } case 5: if perms.GetCanUpdateSelfProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) } case 6: if perms.GetCanUpdateOtherRegularUserLoginMethod() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD) } case 7: if perms.GetCanUpdateWorkspace() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) } } } for _, perm := range permissionsToAdd { if !slices.Contains(loginUser.Permissions, perm) { return updateUserPermError(), nil } } if len(permissionsToAdd) > 0 { events = append(events, workspaceEvent.GrantPermission(id, permissionsToAdd)) } if len(permissionsToRemove) > 0 { events = append(events, workspaceEvent.RevokePermission(id, permissionsToRemove)) } } if err := event.AppendEvents(tx, events); err != nil { logger.Error("Failed to append user update events", "error", err) return updateUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, users); err != nil { logger.Error("Failed to update workspace and users projection") return updateUserSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return updateUserSystemError("Database error"), nil } for _, updated := range users.Projection.Users { if updated.GetId() == id { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_Ok{ Ok: projectionUserToMessage(updated), }, }), nil } } return updateUserSystemError("Updated user not found"), nil } } return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_NotFound{ NotFound: &errorV1.NotFound{ TypeName: proto.String("yamori.workspace.v2.User"), }, }, }), nil }
-
-
-
@@ -0,0 +1,390 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestUpdateUserOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{5}, Name: proto.String("bobber"), DisplayName: proto.String("Cool Bob"), }), ) if err != nil { t.Fatal(err) } updated, ok := update.Msg.Result.(*workspaceV2.UpdateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if updated.Ok.Id.GetValue() != created.Ok.Id.GetValue() { t.Errorf("Expected ID=%s, got ID=%s", created.Ok.Id.GetValue(), updated.Ok.Id.GetValue()) } if updated.Ok.GetName() != "bob" { t.Errorf("name got changed to \"%s\" even though update_fields not containing it", updated.Ok.GetName()) } if updated.Ok.GetDisplayName() != "Cool Bob" { t.Errorf("display_name did not updated correctly: got=%s", updated.Ok.GetDisplayName()) } } func TestUpdateUserInsufficientPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{5}, DisplayName: proto.String("C"), }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { if u.GetDisplayName() != "Carol" { t.Errorf("display_name got updated even on permission_error") } return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestUpdateUserGrantingOverPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanUpdateOtherRegularUserProfile: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{7}, Permissions: &workspaceV2.UserPermissions{ CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { if u.GetDisplayName() != "Carol" { t.Errorf("display_name got updated even on permission_error") } return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestUpdateUserRegularUserTriesToChangeToAdmin(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanUpdateOtherRegularUserProfile: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } bobLoginResult, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: bobLoginResult.Ok.Id, UpdateFields: []int32{6}, IsAdmin: proto.Bool(true), }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == bobLoginResult.Ok.Id.GetValue() { if u.GetIsAdmin() { t.Error("Expected non-admin, got admin") } return } } t.Errorf("User deleted: Cannot find ID=%s", bobLoginResult.Ok.Id.GetValue()) }
-
-
-
@@ -19,6 +19,7 @@ import "yamori/backend/events/workspace/v1/user_created.proto";import "yamori/backend/events/workspace/v1/user_deleted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_granted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_revoked.proto"; import "yamori/backend/events/workspace/v1/user_updated.proto"; import "yamori/backend/events/workspace/v1/workspace_display_name_set.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1";
-
@@ -40,5 +41,6 @@ CustomAttributeDefined custom_attribute_defined = 12;CustomAttributeUndefined custom_attribute_undefined = 13; CustomAttributeSet custom_attribute_set = 14; UserDeleted user_deleted = 15; UserUpdated user_updated = 16; } }
-
-
-
@@ -0,0 +1,14 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserUpdated { string id = 1; string name = 2; string display_name = 3; }
-