diff --git a/internal/api/scim/protocol/list_response.go b/internal/api/scim/protocol/list_response.go new file mode 100644 index 000000000..972229f71 --- /dev/null +++ b/internal/api/scim/protocol/list_response.go @@ -0,0 +1,25 @@ +package protocol + +const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + +type ListResponse[T any] struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []T `json:"Resources"` +} + +func NewListResponse[T any](resources []T) *ListResponse[T] { + if resources == nil { + resources = []T{} + } + n := len(resources) + return &ListResponse[T]{ + Schemas: []string{SchemaListResponse}, + TotalResults: n, + StartIndex: 1, + ItemsPerPage: n, + Resources: resources, + } +} diff --git a/internal/api/scim/protocol/list_response_test.go b/internal/api/scim/protocol/list_response_test.go new file mode 100644 index 000000000..6c4de87bd --- /dev/null +++ b/internal/api/scim/protocol/list_response_test.go @@ -0,0 +1,53 @@ +package protocol + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +const emptyListResponse = `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 0, + "startIndex": 1, + "itemsPerPage": 0, + "Resources": [] +}` + +func TestNewListResponse(t *testing.T) { + for _, tc := range []struct { + name string + resources []string + expected string + }{ + { + name: "nil resources marshal to an empty array", + resources: nil, + expected: emptyListResponse, + }, + { + name: "empty resources marshal to an empty array", + resources: []string{}, + expected: emptyListResponse, + }, + { + name: "populated resources are counted", + resources: []string{"a", "b"}, + expected: `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": ["a", "b"] + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + body, err := json.Marshal(NewListResponse(tc.resources)) + + require.NoError(t, err) + require.JSONEq(t, tc.expected, string(body)) + }) + } +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 8692342b8..d4b479caa 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -29,17 +29,20 @@ func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) } func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w) + return list(w, r, []any{}) } func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w) + return list(w, r, []any{}) } func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error { return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist") } -func (srv *Server) notImplemented(w http.ResponseWriter) error { - return protocol.SendError(w, http.StatusNotImplemented, "", "The request endpoint is not implemented") +func list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { + if r.URL.Query().Get("filter") != "" { + return protocol.SendError(w, http.StatusForbidden, "", "Filtering is not supported on this endpoint") + } + return protocol.Send(w, http.StatusOK, protocol.NewListResponse(resources)) } diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index 55f6541f3..773638bcd 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -4,6 +4,7 @@ import ( "embed" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -59,9 +60,21 @@ func TestServer(t *testing.T) { w := httptest.NewRecorder() require.NoError(t, tc.handler(w, r)) - require.Equal(t, http.StatusNotImplemented, w.Code) - require.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) - require.JSONEq(t, testFixture(t, "not_implemented.json"), w.Body.String()) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "empty_list_response.json"), w.Body.String()) + }) + + t.Run(tc.path+" rejects filter query parameter", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path+"?"+filter, nil) + w := httptest.NewRecorder() + + require.NoError(t, tc.handler(w, r)) + + require.Equal(t, http.StatusForbidden, w.Code) + require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) }) } diff --git a/internal/api/scim/testdata/empty_list_response.json b/internal/api/scim/testdata/empty_list_response.json new file mode 100644 index 000000000..d13e376c6 --- /dev/null +++ b/internal/api/scim/testdata/empty_list_response.json @@ -0,0 +1,9 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "totalResults": 0, + "startIndex": 1, + "itemsPerPage": 0, + "Resources": [] +} diff --git a/internal/api/scim/testdata/filter_forbidden.json b/internal/api/scim/testdata/filter_forbidden.json new file mode 100644 index 000000000..5f060363c --- /dev/null +++ b/internal/api/scim/testdata/filter_forbidden.json @@ -0,0 +1,7 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "detail": "Filtering is not supported on this endpoint", + "status": "403" +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 13dde2ed5..a6a966823 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -24,12 +25,6 @@ var scimPaths = []string{ scimSchemasPath, } -// scimNotImplementedPaths shrinks to empty as the endpoints land. -var scimNotImplementedPaths = []string{ - scimResourceTypesPath, - scimSchemasPath, -} - func TestSCIM(t *testing.T) { t.Run("Disabled by default", func(t *testing.T) { api, _, err := setupAPIForTest() @@ -80,14 +75,26 @@ func TestSCIM(t *testing.T) { require.Contains(t, w.Body.String(), scimCore.SchemaServiceProviderConfig) }) - for _, path := range scimNotImplementedPaths { + for _, path := range []string{scimResourceTypesPath, scimSchemasPath} { t.Run(path, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() api.handler.ServeHTTP(w, r) - require.Equal(t, http.StatusNotImplemented, w.Code) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse) + }) + + t.Run(path+" rejects filter query parameter", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, path+"?"+filter, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusForbidden, w.Code) require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) require.Contains(t, w.Body.String(), scimProtocol.SchemaError) })