api_entity_argument_acl_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. package api
  2. import (
  3. "context"
  4. "testing"
  5. "connectrpc.com/connect"
  6. "github.com/stretchr/testify/assert"
  7. "github.com/stretchr/testify/require"
  8. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  9. config "github.com/OliveTin/OliveTin/internal/config"
  10. "github.com/OliveTin/OliveTin/internal/entities"
  11. "github.com/OliveTin/OliveTin/internal/executor"
  12. )
  13. func buildEntityArgumentGuessConfig() *config.Config {
  14. cfg := config.DefaultConfig()
  15. cfg.DefaultPermissions.View = false
  16. cfg.DefaultPermissions.Exec = false
  17. cfg.AccessControlLists = []*config.AccessControlList{
  18. {
  19. Name: "ops",
  20. MatchUsernames: []string{"admin"},
  21. Permissions: config.PermissionsList{View: true, Exec: true},
  22. },
  23. {
  24. Name: "everyone",
  25. MatchUsernames: []string{"guest", "admin"},
  26. Permissions: config.PermissionsList{View: true, Exec: true},
  27. AddToEveryAction: true,
  28. },
  29. }
  30. cfg.Entities = []*config.EntityFile{
  31. {Name: "servers", File: "servers.yaml", Acls: []string{"ops"}},
  32. }
  33. cfg.Actions = []*config.Action{
  34. {
  35. ID: "reboot-server",
  36. Title: "Reboot server",
  37. Shell: "echo reboot '{{ target }}'",
  38. Arguments: []config.ActionArgument{
  39. {
  40. Name: "target",
  41. Title: "Server",
  42. Entity: "servers",
  43. Choices: []config.ActionArgumentChoice{
  44. {Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
  45. },
  46. },
  47. },
  48. },
  49. }
  50. cfg.Sanitize()
  51. return cfg
  52. }
  53. func seedEntityArgumentGuessEntities(t *testing.T) {
  54. t.Helper()
  55. entities.ClearEntitiesOfType("servers")
  56. t.Cleanup(func() {
  57. entities.ClearEntitiesOfType("servers")
  58. })
  59. entities.AddEntity("servers", "0", map[string]any{"name": "web01"})
  60. entities.AddEntity("servers", "1", map[string]any{"name": "db01"})
  61. }
  62. func TestStartActionRejectsGuessedRestrictedEntityArgument(t *testing.T) {
  63. seedEntityArgumentGuessEntities(t)
  64. cfg := buildEntityArgumentGuessConfig()
  65. ex := executor.DefaultExecutor(cfg)
  66. ex.RebuildActionMap()
  67. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  68. defer ts.Close()
  69. _, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  70. BindingId: "reboot-server",
  71. Arguments: []*apiv1.StartActionArgument{
  72. {Name: "target", Value: "web01"},
  73. },
  74. }))
  75. require.Error(t, err)
  76. assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
  77. }
  78. func TestStartActionRejectsUnknownEntityArgumentValue(t *testing.T) {
  79. seedEntityArgumentGuessEntities(t)
  80. cfg := buildEntityArgumentGuessConfig()
  81. // Make servers unrestricted so guests can view the type but not invent values.
  82. cfg.Entities[0].Acls = nil
  83. cfg.Sanitize()
  84. ex := executor.DefaultExecutor(cfg)
  85. ex.RebuildActionMap()
  86. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  87. defer ts.Close()
  88. _, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  89. BindingId: "reboot-server",
  90. Arguments: []*apiv1.StartActionArgument{
  91. {Name: "target", Value: "not-a-real-server"},
  92. },
  93. }))
  94. require.Error(t, err)
  95. assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
  96. }
  97. func TestStartActionAllowsListedEntityArgumentValue(t *testing.T) {
  98. seedEntityArgumentGuessEntities(t)
  99. cfg := buildEntityArgumentGuessConfig()
  100. cfg.Entities[0].Acls = nil
  101. cfg.Sanitize()
  102. ex := executor.DefaultExecutor(cfg)
  103. ex.RebuildActionMap()
  104. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  105. defer ts.Close()
  106. resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  107. BindingId: "reboot-server",
  108. Arguments: []*apiv1.StartActionArgument{
  109. {Name: "target", Value: "web01"},
  110. },
  111. }))
  112. require.NoError(t, err)
  113. assert.NotEmpty(t, resp.Msg.ExecutionTrackingId)
  114. }
  115. func TestChecklistEntityValuesAllowedRejectsBlankOnlyInput(t *testing.T) {
  116. allowed := map[string]struct{}{"web01": {}, "db01": {}}
  117. arg := &config.ActionArgument{Type: "checklist"}
  118. assert.False(t, checklistEntityValuesAllowed(arg, ",,,", allowed))
  119. assert.False(t, checklistEntityValuesAllowed(arg, " , ", allowed))
  120. assert.False(t, checklistEntityValuesAllowed(arg, "", allowed),
  121. "all-blank checklist parts are rejected here; empty string is accepted by the caller separately")
  122. assert.True(t, checklistEntityValuesAllowed(arg, "web01", allowed))
  123. assert.True(t, checklistEntityValuesAllowed(arg, `["web01","db01"]`, allowed))
  124. assert.False(t, checklistEntityValuesAllowed(arg, `["web01","unknown"]`, allowed))
  125. }
  126. func TestChecklistEntityValuesAllowedAcceptsJSONArrayWithEntityTitles(t *testing.T) {
  127. entities.ClearEntitiesOfType("servers")
  128. t.Cleanup(func() {
  129. entities.ClearEntitiesOfType("servers")
  130. })
  131. entities.AddEntity("servers", "0", map[string]any{"name": "web01", "label": "Web Server One"})
  132. entities.AddEntity("servers", "1", map[string]any{"name": "db01", "label": "Database One"})
  133. arg := &config.ActionArgument{
  134. Type: "checklist",
  135. Entity: "servers",
  136. Choices: []config.ActionArgumentChoice{
  137. {Title: "{{ servers.label }}", Value: "{{ servers.name }}"},
  138. },
  139. }
  140. allowed := entityArgumentAllowedValues(arg)
  141. assert.True(t, checklistEntityValuesAllowed(arg, `["Web Server One","Database One"]`, allowed))
  142. assert.False(t, checklistEntityValuesAllowed(arg, `["Web Server One","unknown"]`, allowed))
  143. }
  144. func TestEntityArgumentValueAllowedAcceptsEntityChoiceTitle(t *testing.T) {
  145. entities.ClearEntitiesOfType("servers")
  146. t.Cleanup(func() {
  147. entities.ClearEntitiesOfType("servers")
  148. })
  149. entities.AddEntity("servers", "0", map[string]any{"name": "web01", "label": "Web Server One"})
  150. arg := &config.ActionArgument{
  151. Entity: "servers",
  152. Choices: []config.ActionArgumentChoice{
  153. {Title: "{{ servers.label }}", Value: "{{ servers.name }}"},
  154. },
  155. }
  156. assert.True(t, entityArgumentValueAllowed(arg, "Web Server One"))
  157. assert.True(t, entityArgumentValueAllowed(arg, "web01"))
  158. assert.False(t, entityArgumentValueAllowed(arg, "unknown"))
  159. }
  160. func TestStartActionRejectsMalformedMultiChoiceEntityArgument(t *testing.T) {
  161. seedEntityArgumentGuessEntities(t)
  162. cfg := buildEntityArgumentGuessConfig()
  163. // After sanitize, force an invalid entity+multi-choice shape that would
  164. // previously skip ACL and fall through to static UI choices.
  165. cfg.Actions[0].Arguments[0].Choices = []config.ActionArgumentChoice{
  166. {Title: "{{ servers.name }}", Value: "{{ servers.name }}"},
  167. {Title: "web01", Value: "web01"},
  168. }
  169. ex := executor.DefaultExecutor(cfg)
  170. ex.RebuildActionMap()
  171. ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
  172. defer ts.Close()
  173. _, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
  174. BindingId: "reboot-server",
  175. Arguments: []*apiv1.StartActionArgument{
  176. {Name: "target", Value: "web01"},
  177. },
  178. }))
  179. require.Error(t, err)
  180. assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
  181. }