Skip to content

Commit 0fb56ca

Browse files
authored
feat(scim): Add /ResourceTypes and /Schemas (#2664)
## What kind of change does this PR introduce? Feature. `GET /scim/v2/ResourceTypes` and `GET /scim/v2/Schemas` return an empty `ListResponse`. ## What is the current behavior? `ServiceProviderConfig` returns a real document, but `ResourceTypes` and `Schemas` still return 501 in the RFC 7644 error form. ## What is the new behavior? Adds the query response form from RFC 7644, and both endpoints answer with an empty one. No resource types exist yet. ```bash $ curl -s http://localhost:9999/scim/v2/Schemas | jq { "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ], "totalResults": 0, "startIndex": 1, "itemsPerPage": 0, "Resources": [] } ``` Neither endpoint supports filtering. RFC 7644, Section 4: > Query parameters described in Section 3.4.2, such as filtering, sorting, and > pagination, SHALL be ignored. If a "filter" is provided, the service provider > SHOULD respond with HTTP status code 403 (Forbidden) to ensure that clients > cannot incorrectly assume that any matching conditions specified in a filter > are true. ```bash $ curl -s 'http://localhost:9999/scim/v2/Schemas?filter=name%20eq%20%22User%22' | jq { "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ], "detail": "Filtering is not supported on this endpoint", "status": "403" } ```
1 parent 3ac3620 commit 0fb56ca

7 files changed

Lines changed: 132 additions & 15 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package protocol
2+
3+
const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
4+
5+
type ListResponse[T any] struct {
6+
Schemas []string `json:"schemas"`
7+
TotalResults int `json:"totalResults"`
8+
StartIndex int `json:"startIndex"`
9+
ItemsPerPage int `json:"itemsPerPage"`
10+
Resources []T `json:"Resources"`
11+
}
12+
13+
func NewListResponse[T any](resources []T) *ListResponse[T] {
14+
if resources == nil {
15+
resources = []T{}
16+
}
17+
n := len(resources)
18+
return &ListResponse[T]{
19+
Schemas: []string{SchemaListResponse},
20+
TotalResults: n,
21+
StartIndex: 1,
22+
ItemsPerPage: n,
23+
Resources: resources,
24+
}
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package protocol
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
const emptyListResponse = `{
11+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
12+
"totalResults": 0,
13+
"startIndex": 1,
14+
"itemsPerPage": 0,
15+
"Resources": []
16+
}`
17+
18+
func TestNewListResponse(t *testing.T) {
19+
for _, tc := range []struct {
20+
name string
21+
resources []string
22+
expected string
23+
}{
24+
{
25+
name: "nil resources marshal to an empty array",
26+
resources: nil,
27+
expected: emptyListResponse,
28+
},
29+
{
30+
name: "empty resources marshal to an empty array",
31+
resources: []string{},
32+
expected: emptyListResponse,
33+
},
34+
{
35+
name: "populated resources are counted",
36+
resources: []string{"a", "b"},
37+
expected: `{
38+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
39+
"totalResults": 2,
40+
"startIndex": 1,
41+
"itemsPerPage": 2,
42+
"Resources": ["a", "b"]
43+
}`,
44+
},
45+
} {
46+
t.Run(tc.name, func(t *testing.T) {
47+
body, err := json.Marshal(NewListResponse(tc.resources))
48+
49+
require.NoError(t, err)
50+
require.JSONEq(t, tc.expected, string(body))
51+
})
52+
}
53+
}

internal/api/scim/server.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,20 @@ func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request)
2929
}
3030

3131
func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error {
32-
return srv.notImplemented(w)
32+
return list(w, r, []any{})
3333
}
3434

3535
func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error {
36-
return srv.notImplemented(w)
36+
return list(w, r, []any{})
3737
}
3838

3939
func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error {
4040
return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist")
4141
}
4242

43-
func (srv *Server) notImplemented(w http.ResponseWriter) error {
44-
return protocol.SendError(w, http.StatusNotImplemented, "", "The request endpoint is not implemented")
43+
func list[T any](w http.ResponseWriter, r *http.Request, resources []T) error {
44+
if r.URL.Query().Has("filter") {
45+
return protocol.SendError(w, http.StatusForbidden, "", "Filtering is not supported on this endpoint")
46+
}
47+
return protocol.Send(w, http.StatusOK, protocol.NewListResponse(resources))
4548
}

internal/api/scim/server_test.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"embed"
55
"net/http"
66
"net/http/httptest"
7+
"net/url"
78
"testing"
89

910
"github.com/stretchr/testify/require"
@@ -59,9 +60,21 @@ func TestServer(t *testing.T) {
5960
w := httptest.NewRecorder()
6061

6162
require.NoError(t, tc.handler(w, r))
62-
require.Equal(t, http.StatusNotImplemented, w.Code)
63-
require.Equal(t, "application/scim+json", w.Header().Get("Content-Type"))
64-
require.JSONEq(t, testFixture(t, "not_implemented.json"), w.Body.String())
63+
64+
require.Equal(t, http.StatusOK, w.Code)
65+
require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type"))
66+
require.JSONEq(t, testFixture(t, "empty_list_response.json"), w.Body.String())
67+
})
68+
69+
t.Run(tc.path+" rejects filter query parameter", func(t *testing.T) {
70+
filter := url.Values{"filter": {`name eq "User"`}}.Encode()
71+
r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path+"?"+filter, nil)
72+
w := httptest.NewRecorder()
73+
74+
require.NoError(t, tc.handler(w, r))
75+
76+
require.Equal(t, http.StatusForbidden, w.Code)
77+
require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String())
6578
})
6679
}
6780

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"schemas": [
3+
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
4+
],
5+
"totalResults": 0,
6+
"startIndex": 1,
7+
"itemsPerPage": 0,
8+
"Resources": []
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"schemas": [
3+
"urn:ietf:params:scim:api:messages:2.0:Error"
4+
],
5+
"detail": "Filtering is not supported on this endpoint",
6+
"status": "403"
7+
}

internal/api/scim_test.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"net/http"
55
"net/http/httptest"
6+
"net/url"
67
"testing"
78

89
"github.com/stretchr/testify/require"
@@ -24,12 +25,6 @@ var scimPaths = []string{
2425
scimSchemasPath,
2526
}
2627

27-
// scimNotImplementedPaths shrinks to empty as the endpoints land.
28-
var scimNotImplementedPaths = []string{
29-
scimResourceTypesPath,
30-
scimSchemasPath,
31-
}
32-
3328
func TestSCIM(t *testing.T) {
3429
t.Run("Disabled by default", func(t *testing.T) {
3530
api, _, err := setupAPIForTest()
@@ -80,14 +75,26 @@ func TestSCIM(t *testing.T) {
8075
require.Contains(t, w.Body.String(), scimCore.SchemaServiceProviderConfig)
8176
})
8277

83-
for _, path := range scimNotImplementedPaths {
78+
for _, path := range []string{scimResourceTypesPath, scimSchemasPath} {
8479
t.Run(path, func(t *testing.T) {
8580
r := httptest.NewRequest(http.MethodGet, path, nil)
8681
w := httptest.NewRecorder()
8782

8883
api.handler.ServeHTTP(w, r)
8984

90-
require.Equal(t, http.StatusNotImplemented, w.Code)
85+
require.Equal(t, http.StatusOK, w.Code)
86+
require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type"))
87+
require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse)
88+
})
89+
90+
t.Run(path+" rejects filter query parameter", func(t *testing.T) {
91+
filter := url.Values{"filter": {`name eq "User"`}}.Encode()
92+
r := httptest.NewRequest(http.MethodGet, path+"?"+filter, nil)
93+
w := httptest.NewRecorder()
94+
95+
api.handler.ServeHTTP(w, r)
96+
97+
require.Equal(t, http.StatusForbidden, w.Code)
9198
require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type"))
9299
require.Contains(t, w.Body.String(), scimProtocol.SchemaError)
93100
})

0 commit comments

Comments
 (0)