diff --git a/bin/evening_detective_server b/bin/evening_detective_server index b866579..3114aef 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/internal/app/game_mapper.go b/internal/app/game_mapper.go index 1d59b6b..c08a774 100644 --- a/internal/app/game_mapper.go +++ b/internal/app/game_mapper.go @@ -52,3 +52,19 @@ func mapGames( } return res } + +func mapGameSummary(game *game_model.Game) *proto.Game { + location := "" + if game.Location != nil { + location = *game.Location + } + return &proto.Game{ + ID: int32(game.ID), + Name: game.Name, + Description: game.Description, + Format: game.Format, + Location: location, + StartAt: timestamppb.New(game.StartAt), + Status: game.Status, + } +} diff --git a/internal/app/games.go b/internal/app/games.go index 51aa26e..7c4d83d 100644 --- a/internal/app/games.go +++ b/internal/app/games.go @@ -3,6 +3,7 @@ package app import ( "context" + "evening_detective_server/internal/modules/processor_jwt" "evening_detective_server/internal/modules/roles" proto "evening_detective_server/proto" @@ -73,10 +74,19 @@ func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.Get if claimsErr != nil { return nil, claimsErr } - if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) { + allowed, restricted := gameReadAccess(claims, s.uiService != nil && s.uiService.Environment == "venue") + if !allowed { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } + if restricted { + game, err := s.gameService.GetGame(ctx, int(req.ID)) + if err != nil { + return nil, gameErrorStatus(err, "не удалось получить игру") + } + return &proto.GetGameRsp{Game: mapGameSummary(game)}, nil + } + game, err := s.gameService.GetFullGame(ctx, int(req.ID)) if err != nil { return nil, gameErrorStatus(err, "не удалось получить игру") @@ -132,7 +142,8 @@ func (s *server) GetGames(ctx context.Context, _ *proto.GetGamesReq) (*proto.Get if claimsErr != nil { return nil, claimsErr } - if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) { + allowed, restricted := gameReadAccess(claims, s.uiService != nil && s.uiService.Environment == "venue") + if !allowed { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } @@ -141,9 +152,14 @@ func (s *server) GetGames(ctx context.Context, _ *proto.GetGamesReq) (*proto.Get return nil, gameErrorStatus(err, "не удалось получить игры") } - return &proto.GetGamesRsp{ - Games: mapGames(games), - }, nil + if restricted { + result := make([]*proto.Game, 0, len(games)) + for _, game := range games { + result = append(result, mapGameSummary(game)) + } + return &proto.GetGamesRsp{Games: result}, nil + } + return &proto.GetGamesRsp{Games: mapGames(games)}, nil } func (s *server) UpdateGame(ctx context.Context, req *proto.UpdateGameReq) (*proto.UpdateGameRsp, error) { @@ -260,3 +276,13 @@ func (s *server) ResetGame(ctx context.Context, req *proto.ResetGameReq) (*proto return &proto.ResetGameRsp{}, nil } + +func gameReadAccess(claims *processor_jwt.JWTClaims, venue bool) (allowed, restricted bool) { + if roles.HasRole(claims, roles.Organizer) || roles.HasRole(claims, roles.Author) { + return true, false + } + if venue && roles.HasRole(claims, roles.Developer) { + return true, true + } + return false, false +} diff --git a/internal/app/games_permissions_test.go b/internal/app/games_permissions_test.go new file mode 100644 index 0000000..341a130 --- /dev/null +++ b/internal/app/games_permissions_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "context" + "reflect" + "testing" + "time" + + "evening_detective_server/internal/modules/processor_jwt" + "evening_detective_server/internal/services/game_model" + main "evening_detective_server/proto" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestGameReadAccess(t *testing.T) { + for _, tc := range []struct { + name string + venue bool + roles []string + allowed, restricted bool + }{ + {"developer venue", true, []string{"developer"}, true, true}, + {"developer prod", false, []string{"developer"}, false, false}, + {"user venue", true, []string{"user"}, false, false}, + {"developer organizer", true, []string{"developer", "organizer"}, true, false}, + {"developer author", true, []string{"developer", "author"}, true, false}, + {"admin prod", false, []string{"admin"}, true, false}, + {"organizer prod", false, []string{"organizer"}, true, false}, + {"author prod", false, []string{"author"}, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + allowed, restricted := gameReadAccess(&processor_jwt.JWTClaims{Roles: tc.roles}, tc.venue) + if allowed != tc.allowed || restricted != tc.restricted { + t.Fatalf("access = %v, %v", allowed, restricted) + } + }) + } +} + +func TestGameSummaryExcludesPrivateFields(t *testing.T) { + for _, location := range []*string{nil, ptrGameLocation("venue")} { + t.Run("summary", func(t *testing.T) { + game := &game_model.Game{ID: 7, Name: "name", Description: "description", Format: "offline", Location: location, StartAt: time.Unix(123, 0), Status: "started"} + // Populate every non-summary field to catch accidental exposure when the model grows. + value := reflect.ValueOf(game).Elem() + for i := 0; i < value.NumField(); i++ { + field := value.Field(i) + if field.Kind() == reflect.Pointer && field.IsNil() && value.Type().Field(i).Name != "Location" { + field.Set(reflect.New(field.Type().Elem())) + } + if field.Kind() == reflect.Slice { + field.Set(reflect.MakeSlice(field.Type(), 1, 1)) + } + } + want := &main.Game{ID: 7, Name: "name", Description: "description", Format: "offline", StartAt: timestamppb.New(game.StartAt), Status: "started"} + if location != nil { + want.Location = *location + } + if !proto.Equal(mapGameSummary(game), want) { + t.Fatal("summary exposes private fields or loses public fields") + } + }) + } +} + +func ptrGameLocation(value string) *string { return &value } + +func TestDeveloperCannotManageGames(t *testing.T) { + s := &server{} + ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{Roles: []string{"developer"}}) + for _, tc := range []struct { + name string + call func() error + }{ + {"add", func() error { _, err := s.AddGame(ctx, &main.AddGameReq{}); return err }}, + {"update", func() error { _, err := s.UpdateGame(ctx, &main.UpdateGameReq{}); return err }}, + {"delete", func() error { _, err := s.DeleteGame(ctx, &main.DeleteGameReq{}); return err }}, + {"start", func() error { _, err := s.StartGame(ctx, &main.StartGameReq{}); return err }}, + {"pause", func() error { _, err := s.PauseGame(ctx, &main.PauseGameReq{}); return err }}, + {"play", func() error { _, err := s.PlayGame(ctx, &main.PlayGameReq{}); return err }}, + {"finish", func() error { _, err := s.FinishGame(ctx, &main.FinishGameReq{}); return err }}, + {"reset", func() error { _, err := s.ResetGame(ctx, &main.ResetGameReq{}); return err }}, + {"answers", func() error { _, err := s.GetGameAnswers(ctx, &main.GetGameAnswersReq{}); return err }}, + {"results", func() error { _, err := s.GetGameResults(ctx, &main.GetGameResultsReq{}); return err }}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); status.Code(err) != codes.PermissionDenied { + t.Fatal(err) + } + }) + } +} diff --git a/internal/services/ui_service/game_permissions_test.go b/internal/services/ui_service/game_permissions_test.go new file mode 100644 index 0000000..deca379 --- /dev/null +++ b/internal/services/ui_service/game_permissions_test.go @@ -0,0 +1,19 @@ +package ui_service + +import ( + "slices" + "testing" +) + +func TestDeveloperGamesPermission(t *testing.T) { + for _, venue := range []bool{false, true} { + t.Run(map[bool]string{true: "venue", false: "prod"}[venue], func(t *testing.T) { + permissions := NewUIService(venue).GetPermissions([]string{"developer"}) + for _, permission := range []string{"games_page", "game_panel_page"} { + if slices.Contains(permissions, permission) != venue { + t.Fatalf("permissions = %v", permissions) + } + } + }) + } +} diff --git a/internal/services/ui_service/service.go b/internal/services/ui_service/service.go index 4f61f42..5af5679 100644 --- a/internal/services/ui_service/service.go +++ b/internal/services/ui_service/service.go @@ -72,5 +72,9 @@ func (s *UIService) getPermissionsMap(roles []string) map[string]any { resMap[permission] = struct{}{} } } + if s.Environment == "venue" && slices.Contains(roles, rolesModule.Developer) { + resMap["games_page"] = struct{}{} + resMap["game_panel_page"] = struct{}{} + } return resMap }