Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: refactor parameter parsing in ListIdentities and disallow combining filters #4244

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,12 @@ require (
go.opentelemetry.io/otel v1.32.0
go.opentelemetry.io/otel/sdk v1.32.0
go.opentelemetry.io/otel/trace v1.32.0
golang.org/x/crypto v0.29.0
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/net v0.31.0
golang.org/x/oauth2 v0.24.0
golang.org/x/sync v0.9.0
golang.org/x/text v0.20.0
golang.org/x/sync v0.10.0
golang.org/x/text v0.21.0
google.golang.org/grpc v1.67.1
)

Expand All @@ -118,7 +118,7 @@ require (
github.com/dgraph-io/ristretto/v2 v2.0.0 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/rjeczalik/notify v0.9.3 // indirect
golang.org/x/term v0.26.0 // indirect
golang.org/x/term v0.27.0 // indirect
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
mvdan.cc/sh/v3 v3.6.0 // indirect
)
Expand Down Expand Up @@ -313,7 +313,7 @@ require (
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.19.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.23.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
Expand Down
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -866,8 +866,8 @@ golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
Expand Down Expand Up @@ -981,8 +981,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
Expand Down Expand Up @@ -1047,8 +1047,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
Expand All @@ -1060,8 +1060,8 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand All @@ -1074,8 +1074,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
Expand Down
127 changes: 69 additions & 58 deletions identity/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,7 @@
// Paginated Identity List Response
//
// swagger:response listIdentities
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type listIdentitiesResponse struct {
type _ struct {
migrationpagination.ResponseHeaderAnnotation

// List of identities
Expand All @@ -133,11 +130,10 @@

// Paginated List Identity Parameters
//
// swagger:parameters listIdentities
// Note: Filters cannot be combined.
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type listIdentitiesParameters struct {
// swagger:parameters listIdentities
type _ struct {
migrationpagination.RequestParameters

// List of ids used to filter identities.
Expand Down Expand Up @@ -183,11 +179,73 @@
crdbx.ConsistencyRequestParameters
}

func parseListIdentitiesParameters(r *http.Request) (params ListIdentityParameters, err error) {
query := r.URL.Query()
var requestedFilters int

params.Expand = ExpandDefault

if ids := query["ids"]; len(ids) > 0 {
requestedFilters++
for _, v := range ids {
id, err := uuid.FromString(v)
if err != nil {
return params, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid UUID value `%s` for parameter `ids`.", v))
}
params.IdsFilter = append(params.IdsFilter, id)
}
}
if len(params.IdsFilter) > 500 {
return params, errors.WithStack(herodot.ErrBadRequest.WithReason("The number of ids to filter must not exceed 500."))
}

if orgID := query.Get("organization_id"); orgID != "" {
requestedFilters++
params.OrganizationID, err = uuid.FromString(orgID)
if err != nil {
return params, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid UUID value `%s` for parameter `organization_id`.", orgID))
}
}

if identifier := query.Get("credentials_identifier"); identifier != "" {
requestedFilters++
params.Expand = ExpandEverything
params.CredentialsIdentifier = identifier
}

if identifier := query.Get("credentials_identifier_similar"); identifier != "" {
requestedFilters++
params.Expand = ExpandEverything
params.CredentialsIdentifierSimilar = identifier
}

for _, v := range query["include_credential"] {
params.Expand = ExpandEverything
tc, ok := ParseCredentialsType(v)
if !ok {
return params, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid value `%s` for parameter `include_credential`.", v))
}
params.DeclassifyCredentials = append(params.DeclassifyCredentials, tc)
}

if requestedFilters > 1 {
return params, errors.WithStack(herodot.ErrBadRequest.WithReason("You cannot combine multiple filters in this API"))
}
Comment on lines +231 to +233
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider to reduce the impact of this breaking change by just ignoring additional filters. If ids=a&ids=b&organization_id=x previously returned only a, it could now return a,b ignoring the second filter. The result should always be a super-set of the previous version, therefore significantly limiting the impact.
On the other side, someone might rely on the behavior and this could in the worst case lead to security issues if the assumption is that all returned identities are part of the given organization.
WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've checked our logs and could not find anyone combining filters, so I think we're good.

Changing semantics silently is a recipe for disaster.


params.KeySetPagination, params.PagePagination, err = x.ParseKeysetOrPagePagination(r)
if err != nil {
return params, err

Check warning on line 237 in identity/handler.go

View check run for this annotation

Codecov / codecov/patch

identity/handler.go#L237

Added line #L237 was not covered by tests
}
params.ConsistencyLevel = crdbx.ConsistencyLevelFromRequest(r)

return params, nil
}

// swagger:route GET /admin/identities identity listIdentities
//
// # List Identities
//
// Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system.
// Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system. Note: filters cannot be combined.
//
// Produces:
// - application/json
Expand All @@ -201,54 +259,7 @@
// 200: listIdentities
// default: errorGeneric
func (h *Handler) list(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
includeCredentials := r.URL.Query()["include_credential"]
var err error
var declassify []CredentialsType
for _, v := range includeCredentials {
tc, ok := ParseCredentialsType(v)
if ok {
declassify = append(declassify, tc)
} else {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid value `%s` for parameter `include_credential`.", declassify)))
return
}
}

var orgId uuid.UUID
if orgIdStr := r.URL.Query().Get("organization_id"); orgIdStr != "" {
orgId, err = uuid.FromString(r.URL.Query().Get("organization_id"))
if err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid UUID value `%s` for parameter `organization_id`.", r.URL.Query().Get("organization_id"))))
return
}
}
var idsFilter []uuid.UUID
for _, v := range r.URL.Query()["ids"] {
id, err := uuid.FromString(v)
if err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Invalid UUID value `%s` for parameter `ids`.", v)))
return
}
idsFilter = append(idsFilter, id)
}

params := ListIdentityParameters{
Expand: ExpandDefault,
IdsFilter: idsFilter,
CredentialsIdentifier: r.URL.Query().Get("credentials_identifier"),
CredentialsIdentifierSimilar: r.URL.Query().Get("preview_credentials_identifier_similar"),
OrganizationID: orgId,
ConsistencyLevel: crdbx.ConsistencyLevelFromRequest(r),
DeclassifyCredentials: declassify,
}
if params.CredentialsIdentifier != "" && params.CredentialsIdentifierSimilar != "" {
h.r.Writer().WriteError(w, r, herodot.ErrBadRequest.WithReason("Cannot pass both credentials_identifier and preview_credentials_identifier_similar."))
return
}
if params.CredentialsIdentifier != "" || params.CredentialsIdentifierSimilar != "" || len(params.DeclassifyCredentials) > 0 {
params.Expand = ExpandEverything
}
params.KeySetPagination, params.PagePagination, err = x.ParseKeysetOrPagePagination(r)
params, err := parseListIdentitiesParameters(r)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
Expand All @@ -271,7 +282,7 @@
}
u := *r.URL
pagepagination.PaginationHeader(w, &u, total, params.PagePagination.Page, params.PagePagination.ItemsPerPage)
} else {
} else if nextPage != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextPage should never be nil, and the header is also relevant in case of the last page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using the ID filter, pagination doesn't make sense. In https://github.com/ory-corp/cloud/pull/7471, I'm returning a nil paginator. Mostly because I don't know how to construct a "no-next-page" paginator and didn't want to change ory/x.

u := *r.URL
keysetpagination.Header(w, &u, nextPage)
}
Expand Down
35 changes: 32 additions & 3 deletions identity/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,21 +369,50 @@ func TestHandler(t *testing.T) {
id := x.ParseUUID(res.Get("id").String())
ids = append(ids, id)
}
require.Equal(t, len(ids), identitiesAmount)
require.Len(t, ids, identitiesAmount)
})

t.Run("case=list few identities", func(t *testing.T) {
url := "/identities?ids=" + ids[0].String()
url := "/identities?ids=" + ids[0].String() + "&ids=" + ids[0].String() // duplicate ID is deduplicated in result
for i := 1; i < listAmount; i++ {
url += "&ids=" + ids[i].String()
}
res := get(t, adminTS, url, http.StatusOK)

identities := res.Array()
require.Equal(t, len(identities), listAmount)
require.Len(t, identities, listAmount)
})
})

t.Run("case=list identities by ID is capped at 500", func(t *testing.T) {
url := "/identities?ids=" + x.NewUUID().String()
for i := 0; i < 501; i++ {
url += "&ids=" + x.NewUUID().String()
}
res := get(t, adminTS, url, http.StatusBadRequest)
assert.Contains(t, res.Get("error.reason").String(), "must not exceed 500")
})

t.Run("case=list identities cannot combine filters", func(t *testing.T) {
filters := []string{
"ids=" + x.NewUUID().String(),
"[email protected]",
"credentials_identifier_similar=bar.com",
"organization_id=" + x.NewUUID().String(),
}
for i := range filters {
for j := range filters {
if i == j {
continue // OK to use the same filter multiple times. Behavior varies by filter, though.
}

url := "/identities?" + filters[i] + "&" + filters[j]
res := get(t, adminTS, url, http.StatusBadRequest)
assert.Contains(t, res.Get("error.reason").String(), "cannot combine multiple filters")
}
}
})

t.Run("case=malformed ids should return an error", func(t *testing.T) {
res := get(t, adminTS, "/identities?ids=not-a-uuid", http.StatusBadRequest)
assert.Contains(t, res.Get("error.reason").String(), "Invalid UUID value `not-a-uuid` for parameter `ids`.", "%s", res.Raw)
Expand Down
4 changes: 2 additions & 2 deletions internal/client-go/api_identity.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/httpclient/api_identity.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion spec/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -3930,7 +3930,7 @@
},
"/admin/identities": {
"get": {
"description": "Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system.",
"description": "Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system. Note: filters cannot be combined.",
"operationId": "listIdentities",
"parameters": [
{
Expand Down
2 changes: 1 addition & 1 deletion spec/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
"oryAccessToken": []
}
],
"description": "Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system.",
"description": "Lists all [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model) in the system. Note: filters cannot be combined.",
"produces": [
"application/json"
],
Expand Down
Loading