From 540c89d68e7efbd9043cb0147e10781cd61021a6 Mon Sep 17 00:00:00 2001 From: Matt Bonnell Date: Mon, 5 Apr 2021 08:56:03 -0400 Subject: [PATCH] feat: implement partial client updates (PATCH) with JSON Patch syntax (#2411) Implements a new endpoint `PATCH /clients/{id}` which uses JSON Patch syntax to update an OAuth2 client partially. This removes the need to do `PUT /clients/{id}` with the full OAuth2 Client in the payload. Co-authored-by: hackerman <3372410+aeneasr@users.noreply.github.com> --- client/doc.go | 39 ++++ client/handler.go | 70 +++++++- client/sdk_test.go | 36 ++++ go.mod | 1 + .../httpclient/client/admin/admin_client.go | 44 +++++ .../admin/patch_o_auth2_client_parameters.go | 167 ++++++++++++++++++ .../admin/patch_o_auth2_client_responses.go | 105 +++++++++++ internal/httpclient/models/patch_document.go | 96 ++++++++++ internal/httpclient/models/patch_request.go | 69 ++++++++ internal/httpclient/models/volume.go | 3 +- spec/api.json | 83 +++++++++ x/json.go | 45 +++++ x/json_test.go | 116 ++++++++++++ 13 files changed, 865 insertions(+), 9 deletions(-) create mode 100644 internal/httpclient/client/admin/patch_o_auth2_client_parameters.go create mode 100644 internal/httpclient/client/admin/patch_o_auth2_client_responses.go create mode 100644 internal/httpclient/models/patch_document.go create mode 100644 internal/httpclient/models/patch_request.go create mode 100644 x/json.go create mode 100644 x/json_test.go diff --git a/client/doc.go b/client/doc.go index a2a5a898548..974ba52077e 100644 --- a/client/doc.go +++ b/client/doc.go @@ -47,6 +47,45 @@ type swaggerUpdateClientPayload struct { Body Client } +// swagger:parameters patchOAuth2Client +type swaggerPatchClientPayload struct { + // in: path + // required: true + ID string `json:"id"` + + // in: body + // required: true + Body patchRequest +} + +// A JSONPatch request +// +// swagger:model patchRequest +type patchRequest []patchDocument + +// A JSONPatch document as defined by RFC 6902 +// +// swagger:model patchDocument +type patchDocument struct { + // The operation to be performed + // + // required: true + // example: "replace" + Op string `json:"op"` + + // A JSON-pointer + // + // required: true + // example: "/name" + Path string `json:"path"` + + // The value to be used within the operations + Value interface{} `json:"value"` + + // A JSON-pointer + From string `json:"from"` +} + // swagger:parameters listOAuth2Clients type swaggerListClientsParameter struct { // The maximum amount of policies returned, upper bound is 500 policies diff --git a/client/handler.go b/client/handler.go index 03bade87073..5cbc4f33598 100644 --- a/client/handler.go +++ b/client/handler.go @@ -21,7 +21,9 @@ package client import ( + "context" "encoding/json" + "io" "net/http" "time" @@ -57,6 +59,7 @@ func (h *Handler) SetRoutes(admin *x.RouterAdmin) { admin.POST(ClientsHandlerPath, h.Create) admin.GET(ClientsHandlerPath+"/:id", h.Get) admin.PUT(ClientsHandlerPath+"/:id", h.Update) + admin.PATCH(ClientsHandlerPath+"/:id", h.Patch) admin.DELETE(ClientsHandlerPath+"/:id", h.Delete) } @@ -146,25 +149,76 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request, ps httprouter.P return } - var secret string - if len(c.Secret) > 0 { - secret = c.Secret + c.OutfacingID = ps.ByName("id") + if err := h.updateClient(r.Context(), &c); err != nil { + h.r.Writer().WriteError(w, r, err) + return } - c.OutfacingID = ps.ByName("id") - if err := h.r.ClientValidator().Validate(&c); err != nil { + h.r.Writer().Write(w, r, &c) +} + +// swagger:route PATCH /clients/{id} admin patchOAuth2Client +// +// Patch an OAuth 2.0 Client +// +// Patch an existing OAuth 2.0 Client. If you pass `client_secret` the secret will be updated and returned via the API. This is the only time you will be able to retrieve the client secret, so write it down and keep it safe. +// +// OAuth 2.0 clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. To manage ORY Hydra, you will need an OAuth 2.0 Client as well. Make sure that this endpoint is well protected and only callable by first-party components. +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Responses: +// 200: oAuth2Client +// 500: genericError +func (h *Handler) Patch(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + patchJSON, err := io.ReadAll(r.Body) + if err != nil { h.r.Writer().WriteError(w, r, err) return } - c.UpdatedAt = time.Now().UTC().Round(time.Second) - if err := h.r.ClientManager().UpdateClient(r.Context(), &c); err != nil { + id := ps.ByName("id") + c, err := h.r.ClientManager().GetConcreteClient(r.Context(), id) + if err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + if err := x.ApplyJSONPatch(patchJSON, c, "/id"); err != nil { + h.r.Writer().WriteError(w, r, err) + return + } + + if err := h.updateClient(r.Context(), c); err != nil { h.r.Writer().WriteError(w, r, err) return } + h.r.Writer().Write(w, r, c) +} + +func (h *Handler) updateClient(ctx context.Context, c *Client) error { + var secret string + if len(c.Secret) > 0 { + secret = c.Secret + } + if err := h.r.ClientValidator().Validate(c); err != nil { + return err + } + + c.UpdatedAt = time.Now().UTC().Round(time.Second) + if err := h.r.ClientManager().UpdateClient(ctx, c); err != nil { + return err + } c.Secret = secret - h.r.Writer().Write(w, r, &c) + return nil } // swagger:route GET /clients admin listOAuth2Clients diff --git a/client/sdk_test.go b/client/sdk_test.go index 611883dea11..944ff7880ac 100644 --- a/client/sdk_test.go +++ b/client/sdk_test.go @@ -28,6 +28,7 @@ import ( "testing" "github.com/go-openapi/strfmt" + "github.com/mohae/deepcopy" "github.com/ory/x/pointerx" "github.com/ory/x/urlx" @@ -230,4 +231,39 @@ func TestClientSDK(t *testing.T) { }) } }) + t.Run("case=patch client legally", func(t *testing.T) { + op := "add" + path := "/redirect_uris/-" + value := "http://foo.bar" + + client := createTestClient("") + client.ClientID = "patch1_client" + _, err := c.Admin.CreateOAuth2Client(admin.NewCreateOAuth2ClientParams().WithBody(client)) + require.NoError(t, err) + + expected := deepcopy.Copy(client).(*models.OAuth2Client) + expected.RedirectUris = append(expected.RedirectUris, value) + + result, err := c.Admin.PatchOAuth2Client(admin.NewPatchOAuth2ClientParams().WithID(client.ClientID).WithBody(models.PatchRequest{{Op: &op, Path: &path, Value: value}})) + require.NoError(t, err) + expected.CreatedAt = result.Payload.CreatedAt + expected.UpdatedAt = result.Payload.UpdatedAt + expected.ClientSecret = result.Payload.ClientSecret + expected.ClientSecretExpiresAt = result.Payload.ClientSecretExpiresAt + require.Equal(t, expected, result.Payload) + }) + + t.Run("case=patch client illegally", func(t *testing.T) { + op := "replace" + path := "/id" + value := "foo" + + client := createTestClient("") + client.ClientID = "patch2_client" + _, err := c.Admin.CreateOAuth2Client(admin.NewCreateOAuth2ClientParams().WithBody(client)) + require.NoError(t, err) + + _, err = c.Admin.PatchOAuth2Client(admin.NewPatchOAuth2ClientParams().WithID(client.ClientID).WithBody(models.PatchRequest{{Op: &op, Path: &path, Value: value}})) + require.Error(t, err) + }) } diff --git a/go.mod b/go.mod index ec66fe47d29..f8f1a80b0d0 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ replace github.com/gogo/protobuf => github.com/gogo/protobuf v1.3.2 require ( github.com/cenkalti/backoff/v3 v3.0.0 github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/evanphx/json-patch v0.5.2 github.com/go-bindata/go-bindata v3.1.1+incompatible github.com/go-openapi/errors v0.20.0 github.com/go-openapi/runtime v0.19.26 diff --git a/internal/httpclient/client/admin/admin_client.go b/internal/httpclient/client/admin/admin_client.go index aa14d9d40f8..17607ec8487 100644 --- a/internal/httpclient/client/admin/admin_client.go +++ b/internal/httpclient/client/admin/admin_client.go @@ -72,6 +72,8 @@ type ClientService interface { ListSubjectConsentSessions(params *ListSubjectConsentSessionsParams, opts ...ClientOption) (*ListSubjectConsentSessionsOK, error) + PatchOAuth2Client(params *PatchOAuth2ClientParams, opts ...ClientOption) (*PatchOAuth2ClientOK, error) + Prometheus(params *PrometheusParams, opts ...ClientOption) (*PrometheusOK, error) RejectConsentRequest(params *RejectConsentRequestParams, opts ...ClientOption) (*RejectConsentRequestOK, error) @@ -1018,6 +1020,48 @@ func (a *Client) ListSubjectConsentSessions(params *ListSubjectConsentSessionsPa panic(msg) } +/* + PatchOAuth2Client patches an o auth 2 0 client + + Patch an existing OAuth 2.0 Client. If you pass `client_secret` the secret will be updated and returned via the API. This is the only time you will be able to retrieve the client secret, so write it down and keep it safe. + +OAuth 2.0 clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. To manage ORY Hydra, you will need an OAuth 2.0 Client as well. Make sure that this endpoint is well protected and only callable by first-party components. +*/ +func (a *Client) PatchOAuth2Client(params *PatchOAuth2ClientParams, opts ...ClientOption) (*PatchOAuth2ClientOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewPatchOAuth2ClientParams() + } + op := &runtime.ClientOperation{ + ID: "patchOAuth2Client", + Method: "PATCH", + PathPattern: "/clients/{id}", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http", "https"}, + Params: params, + Reader: &PatchOAuth2ClientReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*PatchOAuth2ClientOK) + if ok { + return success, nil + } + // unexpected success response + // safeguard: normally, absent a default response, unknown success responses return an error above: so this is a codegen issue + msg := fmt.Sprintf("unexpected success response for patchOAuth2Client: API contract not enforced by server. Client expected to get an error, but got: %T", result) + panic(msg) +} + /* Prometheus gets snapshot metrics from the hydra service diff --git a/internal/httpclient/client/admin/patch_o_auth2_client_parameters.go b/internal/httpclient/client/admin/patch_o_auth2_client_parameters.go new file mode 100644 index 00000000000..bb40dbefc65 --- /dev/null +++ b/internal/httpclient/client/admin/patch_o_auth2_client_parameters.go @@ -0,0 +1,167 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package admin + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + + "github.com/ory/hydra/internal/httpclient/models" +) + +// NewPatchOAuth2ClientParams creates a new PatchOAuth2ClientParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewPatchOAuth2ClientParams() *PatchOAuth2ClientParams { + return &PatchOAuth2ClientParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewPatchOAuth2ClientParamsWithTimeout creates a new PatchOAuth2ClientParams object +// with the ability to set a timeout on a request. +func NewPatchOAuth2ClientParamsWithTimeout(timeout time.Duration) *PatchOAuth2ClientParams { + return &PatchOAuth2ClientParams{ + timeout: timeout, + } +} + +// NewPatchOAuth2ClientParamsWithContext creates a new PatchOAuth2ClientParams object +// with the ability to set a context for a request. +func NewPatchOAuth2ClientParamsWithContext(ctx context.Context) *PatchOAuth2ClientParams { + return &PatchOAuth2ClientParams{ + Context: ctx, + } +} + +// NewPatchOAuth2ClientParamsWithHTTPClient creates a new PatchOAuth2ClientParams object +// with the ability to set a custom HTTPClient for a request. +func NewPatchOAuth2ClientParamsWithHTTPClient(client *http.Client) *PatchOAuth2ClientParams { + return &PatchOAuth2ClientParams{ + HTTPClient: client, + } +} + +/* PatchOAuth2ClientParams contains all the parameters to send to the API endpoint + for the patch o auth2 client operation. + + Typically these are written to a http.Request. +*/ +type PatchOAuth2ClientParams struct { + + // Body. + Body models.PatchRequest + + // ID. + ID string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the patch o auth2 client params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PatchOAuth2ClientParams) WithDefaults() *PatchOAuth2ClientParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the patch o auth2 client params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *PatchOAuth2ClientParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) WithTimeout(timeout time.Duration) *PatchOAuth2ClientParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) WithContext(ctx context.Context) *PatchOAuth2ClientParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) WithHTTPClient(client *http.Client) *PatchOAuth2ClientParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithBody adds the body to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) WithBody(body models.PatchRequest) *PatchOAuth2ClientParams { + o.SetBody(body) + return o +} + +// SetBody adds the body to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) SetBody(body models.PatchRequest) { + o.Body = body +} + +// WithID adds the id to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) WithID(id string) *PatchOAuth2ClientParams { + o.SetID(id) + return o +} + +// SetID adds the id to the patch o auth2 client params +func (o *PatchOAuth2ClientParams) SetID(id string) { + o.ID = id +} + +// WriteToRequest writes these params to a swagger request +func (o *PatchOAuth2ClientParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + if o.Body != nil { + if err := r.SetBodyParam(o.Body); err != nil { + return err + } + } + + // path param id + if err := r.SetPathParam("id", o.ID); err != nil { + return err + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/httpclient/client/admin/patch_o_auth2_client_responses.go b/internal/httpclient/client/admin/patch_o_auth2_client_responses.go new file mode 100644 index 00000000000..3d5e1c866ee --- /dev/null +++ b/internal/httpclient/client/admin/patch_o_auth2_client_responses.go @@ -0,0 +1,105 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package admin + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/ory/hydra/internal/httpclient/models" +) + +// PatchOAuth2ClientReader is a Reader for the PatchOAuth2Client structure. +type PatchOAuth2ClientReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *PatchOAuth2ClientReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewPatchOAuth2ClientOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 500: + result := NewPatchOAuth2ClientInternalServerError() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + return nil, runtime.NewAPIError("response status code does not match any response statuses defined for this endpoint in the swagger spec", response, response.Code()) + } +} + +// NewPatchOAuth2ClientOK creates a PatchOAuth2ClientOK with default headers values +func NewPatchOAuth2ClientOK() *PatchOAuth2ClientOK { + return &PatchOAuth2ClientOK{} +} + +/* PatchOAuth2ClientOK describes a response with status code 200, with default header values. + +oAuth2Client +*/ +type PatchOAuth2ClientOK struct { + Payload *models.OAuth2Client +} + +func (o *PatchOAuth2ClientOK) Error() string { + return fmt.Sprintf("[PATCH /clients/{id}][%d] patchOAuth2ClientOK %+v", 200, o.Payload) +} +func (o *PatchOAuth2ClientOK) GetPayload() *models.OAuth2Client { + return o.Payload +} + +func (o *PatchOAuth2ClientOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.OAuth2Client) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewPatchOAuth2ClientInternalServerError creates a PatchOAuth2ClientInternalServerError with default headers values +func NewPatchOAuth2ClientInternalServerError() *PatchOAuth2ClientInternalServerError { + return &PatchOAuth2ClientInternalServerError{} +} + +/* PatchOAuth2ClientInternalServerError describes a response with status code 500, with default header values. + +genericError +*/ +type PatchOAuth2ClientInternalServerError struct { + Payload *models.GenericError +} + +func (o *PatchOAuth2ClientInternalServerError) Error() string { + return fmt.Sprintf("[PATCH /clients/{id}][%d] patchOAuth2ClientInternalServerError %+v", 500, o.Payload) +} +func (o *PatchOAuth2ClientInternalServerError) GetPayload() *models.GenericError { + return o.Payload +} + +func (o *PatchOAuth2ClientInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.GenericError) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/internal/httpclient/models/patch_document.go b/internal/httpclient/models/patch_document.go new file mode 100644 index 00000000000..0f8b9cac0db --- /dev/null +++ b/internal/httpclient/models/patch_document.go @@ -0,0 +1,96 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// PatchDocument A JSONPatch document as defined by RFC 6902 +// +// swagger:model patchDocument +type PatchDocument struct { + + // A JSON-pointer + From string `json:"from,omitempty"` + + // The operation to be performed + // Example: \"replace\ + // Required: true + Op *string `json:"op"` + + // A JSON-pointer + // Example: \"/name\ + // Required: true + Path *string `json:"path"` + + // The value to be used within the operations + Value interface{} `json:"value,omitempty"` +} + +// Validate validates this patch document +func (m *PatchDocument) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateOp(formats); err != nil { + res = append(res, err) + } + + if err := m.validatePath(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *PatchDocument) validateOp(formats strfmt.Registry) error { + + if err := validate.Required("op", "body", m.Op); err != nil { + return err + } + + return nil +} + +func (m *PatchDocument) validatePath(formats strfmt.Registry) error { + + if err := validate.Required("path", "body", m.Path); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this patch document based on context it is used +func (m *PatchDocument) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *PatchDocument) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *PatchDocument) UnmarshalBinary(b []byte) error { + var res PatchDocument + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/internal/httpclient/models/patch_request.go b/internal/httpclient/models/patch_request.go new file mode 100644 index 00000000000..e974c6748e9 --- /dev/null +++ b/internal/httpclient/models/patch_request.go @@ -0,0 +1,69 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// PatchRequest A JSONPatch request +// +// swagger:model patchRequest +type PatchRequest []*PatchDocument + +// Validate validates this patch request +func (m PatchRequest) Validate(formats strfmt.Registry) error { + var res []error + + for i := 0; i < len(m); i++ { + if swag.IsZero(m[i]) { // not required + continue + } + + if m[i] != nil { + if err := m[i].Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName(strconv.Itoa(i)) + } + return err + } + } + + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// ContextValidate validate this patch request based on the context it is used +func (m PatchRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + for i := 0; i < len(m); i++ { + + if m[i] != nil { + if err := m[i].ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName(strconv.Itoa(i)) + } + return err + } + } + + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/internal/httpclient/models/volume.go b/internal/httpclient/models/volume.go index a8a725e2cf8..a27d9734b32 100644 --- a/internal/httpclient/models/volume.go +++ b/internal/httpclient/models/volume.go @@ -42,7 +42,8 @@ type Volume struct { // Required: true Options map[string]string `json:"Options"` - // The level at which the volume exists. Either `global` for cluster-wide, or `local` for machine level. + // The level at which the volume exists. Either `global` for cluster-wide, + // or `local` for machine level. // Required: true Scope *string `json:"Scope"` diff --git a/spec/api.json b/spec/api.json index c51f9ac744d..ff5393477b2 100755 --- a/spec/api.json +++ b/spec/api.json @@ -335,6 +335,54 @@ } } } + }, + "patch": { + "description": "Patch an existing OAuth 2.0 Client. If you pass `client_secret` the secret will be updated and returned via the API. This is the only time you will be able to retrieve the client secret, so write it down and keep it safe.\n\nOAuth 2.0 clients are used to perform OAuth 2.0 and OpenID Connect flows. Usually, OAuth 2.0 clients are generated for applications which want to consume your OAuth 2.0 or OpenID Connect capabilities. To manage ORY Hydra, you will need an OAuth 2.0 Client as well. Make sure that this endpoint is well protected and only callable by first-party components.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "admin" + ], + "summary": "Patch an OAuth 2.0 Client", + "operationId": "patchOAuth2Client", + "parameters": [ + { + "type": "string", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "Body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/patchRequest" + } + } + ], + "responses": { + "200": { + "description": "oAuth2Client", + "schema": { + "$ref": "#/definitions/oAuth2Client" + } + }, + "500": { + "description": "genericError", + "schema": { + "$ref": "#/definitions/genericError" + } + } + } } }, "/health/alive": { @@ -3037,6 +3085,41 @@ } } }, + "patchDocument": { + "description": "A JSONPatch document as defined by RFC 6902", + "type": "object", + "required": [ + "op", + "path" + ], + "properties": { + "from": { + "description": "A JSON-pointer", + "type": "string" + }, + "op": { + "description": "The operation to be performed", + "type": "string", + "example": "\"replace\"" + }, + "path": { + "description": "A JSON-pointer", + "type": "string", + "example": "\"/name\"" + }, + "value": { + "description": "The value to be used within the operations", + "type": "object" + } + } + }, + "patchRequest": { + "description": "A JSONPatch request", + "type": "array", + "items": { + "$ref": "#/definitions/patchDocument" + } + }, "rejectRequest": { "type": "object", "title": "The request payload used to accept a login or consent request.", diff --git a/x/json.go b/x/json.go new file mode 100644 index 00000000000..dfe770632da --- /dev/null +++ b/x/json.go @@ -0,0 +1,45 @@ +package x + +import ( + "encoding/json" + "fmt" + + jsonpatch "github.com/evanphx/json-patch" +) + +func ApplyJSONPatch(p json.RawMessage, object interface{}, denyPaths ...string) error { + patch, err := jsonpatch.DecodePatch(p) + if err != nil { + return err + } + + denySet := make(map[string]struct{}) + for _, path := range denyPaths { + denySet[path] = struct{}{} + } + + for _, op := range patch { + path, err := op.Path() + if err != nil { + return fmt.Errorf("error parsing patch operations: %v", err) + } + if _, ok := denySet[path]; ok { + return fmt.Errorf("patch includes denied path: %s", path) + } + } + + original, err := json.Marshal(object) + if err != nil { + return err + } + + modified, err := patch.Apply(original) + if err != nil { + return err + } + + if err := json.Unmarshal(modified, object); err != nil { + return err + } + return nil +} diff --git a/x/json_test.go b/x/json_test.go new file mode 100644 index 00000000000..61d28614c8f --- /dev/null +++ b/x/json_test.go @@ -0,0 +1,116 @@ +package x + +import ( + "testing" + + "github.com/mohae/deepcopy" + "github.com/stretchr/testify/require" +) + +type TestType struct { + Field1 string + Field2 []string + Field3 struct { + Field1 bool + Field2 []int + } +} + +func TestApplyJSONPatch(t *testing.T) { + object := TestType{ + Field1: "foo", + Field2: []string{ + "foo", + "bar", + "baz", + "kaz", + }, + Field3: struct { + Field1 bool + Field2 []int + }{ + Field1: true, + Field2: []int{ + 1, + 2, + 3, + }, + }, + } + t.Run("case=empty patch", func(t *testing.T) { + rawPatch := []byte(`[]`) + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, object, obj) + }) + t.Run("case=field replace", func(t *testing.T) { + rawPatch := []byte(`[{"op": "replace", "path": "/Field1", "value": "boo"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field1 = "boo" + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=array replace", func(t *testing.T) { + rawPatch := []byte(`[{"op": "replace", "path": "/Field2/0", "value": "boo"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field2[0] = "boo" + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=array append", func(t *testing.T) { + rawPatch := []byte(`[{"op": "add", "path": "/Field2/-", "value": "boo"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field2 = append(expected.Field2, "boo") + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=array remove", func(t *testing.T) { + rawPatch := []byte(`[{"op": "remove", "path": "/Field2/0"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field2 = expected.Field2[1:] + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=nested field replace", func(t *testing.T) { + rawPatch := []byte(`[{"op": "replace", "path": "/Field3/Field1", "value": false}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field3.Field1 = false + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=nested array append", func(t *testing.T) { + rawPatch := []byte(`[{"op": "add", "path": "/Field3/Field2/-", "value": 4}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field3.Field2 = append(expected.Field3.Field2, 4) + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=nested array remove", func(t *testing.T) { + rawPatch := []byte(`[{"op": "remove", "path": "/Field3/Field2/2"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field3.Field2 = expected.Field3.Field2[:2] + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj)) + require.Equal(t, expected, obj) + }) + t.Run("case=patch denied path", func(t *testing.T) { + rawPatch := []byte(`[{"op": "replace", "path": "/Field1", "value": "bar"}]`) + obj := deepcopy.Copy(object).(TestType) + require.Error(t, ApplyJSONPatch(rawPatch, &obj, "/Field1")) + require.Equal(t, object, obj) + }) + t.Run("case=patch allowed path", func(t *testing.T) { + rawPatch := []byte(`[{"op": "add", "path": "/Field2/-", "value": "bar"}]`) + expected := deepcopy.Copy(object).(TestType) + expected.Field2 = append(expected.Field2, "bar") + obj := deepcopy.Copy(object).(TestType) + require.NoError(t, ApplyJSONPatch(rawPatch, &obj, "/Field1")) + require.Equal(t, expected, obj) + }) +}