graphql-engine/rfcs/input-validations.md
hasura-bot 790523556f RFC: Input Validations (for mutations)
GITHUB_PR_NUMBER: 9463
GITHUB_PR_URL: https://github.com/hasura/graphql-engine/pull/9463

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8096
Co-authored-by: Tirumarai Selvan <8663570+tirumaraiselvan@users.noreply.github.com>
GitOrigin-RevId: 81ae4a849fa4c336bdce75f29126d66297139f00
2023-07-26 08:21:07 +00:00

21 KiB

Input Validations

Index

Any mutation input coming from an end user should be allowed to be validated.

For eg: Consider the following mutation:

mutation insertUser($email:String, $name: String) {
  insert_users(objects: [{email: $email, name: $name}]) {
    affected_rows
    returning {
      id
    }
  }
}

We might want to validate that the value provided for the email field is valid. And also restrict the number of row inserts that can happen. Currently, this is not possible in Hasura.

With this RFC we want to give the ability to hook an external (or internal) function for the purposes of validating the arguments provided for a mutation.

Validation for mutations

A new field called validate_input has been introduced in the insert/update/delete permission definition to configure the validation.

Find a sample configuration below.

type: pg_create_(insert|update|delete)_permission

args:
  table: article,
  source: default,
  role: user,
  permission:
    validate_input:
      type: http
      definition:
        url: http://www.somedomain.com/validateArticle
        headers:
        - name: X-Validate-Input-API-Key
          value_from_env: VALIDATION_HOOK_API_KEY
        forward_client_headers: true
        timeout: 5

The type determines the interface for the input validation, which initially only supports http webhook url. However, we may expand support for multiple interfaces such as a Postgres function or a remote schema field.

The definition field provides necessary context for communicating and submitting the data for input validation. It is an object with the following fields.

  • url - Required, a string value which supports templating environment variables.
  • headers - Optional, List of defined headers to be sent to the url.
  • forward_client_headers - Optional, default is false. If set to true the client headers are forwarded to the url.
  • timeout - Optional, default is 10. Number of seconds to wait for response before timing out.

Mutation performance

Mutations that involve input validation may exhibit slower performance compared to mutations without validation. The execution time of the webhook url can become a bottleneck, potentially reaching the maximum limit specified by the timeout configuration value.

Insert Mutations

Behaviour

When an insert mutation comes in with a role, the following steps are performed:

  1. First "collect" all the tables that the mutation targets (because there could be more than one table involved via nested inserts)
  2. If there is a validate_input permission on a table, then any arguments targeting that table are sent to the url. This is done for all tables.
  3. If all handlers validates the insert data (mutation arguments), then the request proceeds. A transaction with the database will only be started after the validation is over and successful.
  4. If any url invalidates the insert data, then the request aborts. An error message from the url can also be forwarded to the client.

Consider the following sample mutation:

mutation insertAuthorWithArticles($name: String, $email:String, $articles_content:[article_insert_input!]!) {
  insert_author(objects: {name: $name, email: $email, articles: {data: $articles_content}}){
    returning {
      first_name
      articles {
        id
      }
    }
  }
}

The mentioned mutation targets the author and article tables, involving a nested insert of article into the author model. Assuming that the validate_input permission is defined for both tables, the validation process unfolds as follows:

  • The validation webhook specified for the author table is contacted first, including the inserted row with articles data.
  • Subsequently, the validation webhook designated for the article table is contacted with $articles_content rows.
  • If both of the above webhook calls result in successful validation, a database transaction is initiated to insert the rows into the respective tables.

Webhook specification

Request

When an insert mutation on a table with validate_input configuration is executed, before making a database transaction Hasura sends the insert data to the validation HTTP webhook using a POST request.

The request payload is of the format:

{
    "version": "<version-integer>",
    "role": "<role-name>",
    "session_variables": {
        "x-hasura-user-id": "<session-user-id>",
        "x-hasura-user-name": "<session-user-name>"
    },
    "data": {
        "input": [JSON-fied <model_name>_insert_input!]
    }
}
  • version: An integer version serves to indicate the request format. Whenever a breaking update occurs in the request payload, the version will be incremented. The initial version is set to 1.
  • role: Hasura session role on which permissions are enforced.
  • session_variables: Session variables that aid in enforcing permissions. Variable names always starts with x-hasura-*.
  • data.objects: List of rows to be inserted which are specified in the objects input field of insert mutation. Also includes nested data of relationships. The structure of this field will be similar to the JSONified structure of the <model_name>_insert_input! graphql type.

Note that, in data.objects if the data to be inserted contains nested inserts, then the data.objects for the:

  1. The Root model has a type of JSON-fied <model_name>_insert_input!, i.e the nested inserts will be present as JSON-fied <model_name>_(arr|obj)_rel_insert_input!
  2. The nested inserts payload has the type JSON-fied <nested_model_name>_insert_input!

Response

  1. Successful Response

The HTTP validation URL should return a 200 status code to represent successful validation.

200 OK
  1. Unsucessful Response

The HTTP validation URL should return a optional JSON object with 400 status code to represent failed validation. The object should contain message field whose value is a string and this message is forwarded to client.

If no JSON object is returned then no message is forwarded to client.

400 BAD REQUEST

{
    "message": "Phone number invalid"
}

When an unexpected response format is received, Hasura raises internal exception.

Examples

  1. [Single model] Check if email is valid when creating a user

Consider the following mutation:

mutation insertUser($email:String, $name: String) {
  insert_users(objects: [{email: $email, name: $name}]) {
    affected_rows
    returning {
      id
    }
  }
}

The arguments used in users model i.e. $email and $name are sent to the validate_input url of users insert permission. The url can check the email value. If the url returns success, then the mutation proceeds else the error from the url is forwarded.

The sample payload the validation URL of user model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
        "input": [
            {
                "name": "Jane",
                "email": "jane@b.com",
            },
            {
                "name": "Doe",
                "email": "doe@b.com",
            }
        ]
    }
}
  1. [Multiple models] Check if article length is less than 1000 when inserting an author with their articles.

The mentioned mutation targets the author and article tables, involving a nested insert of article into the author model.

mutation insertAuthorWithArticles($name: String, $email:String, $articles_content:[article_insert_input!]!) {
  insert_author(objects: [{name: $name, email: $email, articles: {data: $articles_content}}]){
    returning {
      name
      email
      articles {
        id
      }
    }
  }
}

The arguments used in author model i.e. $name, $email and $articles_content (relationship arguments are also considered part of model arguments) are sent to the validate_input url of author model.

The arguments used in article model i.e. $articles_content is sent to the validate_input url of article model.

If both handlers return success, then the mutation proceeds else the error(s) from the url(s) is forwarded.

The sample payload the validation URL of author model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
        "input": [
            {
                "name": "Jane",
                "email":"jane@b.com",
                "articles": {
                  "data":{
                    "id":123
                  }
                }
            },
            {
                "name": "Doe",
                "email":"doe@b.com",
                "articles": {
                  "data":{
                    "id":123
                  }
                }
            }
        ]
    }
}

The sample payload the validation URL of article model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
        "input": [
            {
                "id": 123,
            },
            {
                "id": 345
            }
        ]
    }
}

Update Mutations

Behaviour

When an update mutation comes in with a role, the following steps are performed:

  1. If there is a validate_input permission on a table, then any update arguments targeting that table are sent to the url.
  2. If the handlers validates the update arguments , then the request proceeds. A transaction with the database will only be started after the validation is over and successful.
  3. If any url invalidates the update arguments, then the request aborts. An error message from the url can also be forwarded to the client.

Consider the following sample mutation query:

mutation update_author {
  update_author(where: { id: { _eq: 3 } }, _set: { name: "Jane" }) {
    affected_rows
  }
}

The mentioned mutation targets the author table and wants to update the value present in the table. Assuming that the validate_input permission is defined for the table, the validation process unfolds as follows:

  • The validation webhook specified for the author table is contacted, including the update argument.
  • If the above webhook calls result in successful validation, a database transaction is initiated to update the rows of the respective tables.

Webhook specification

Request

Consider the following sample mutation query:

mutation update_author {
  update_author(where: { id: { _eq: 3 } }, _set: { name: "Jane" }) {
    affected_rows
  }
}

The user may want to validate the input values in the where, _set, _inc clause and pk_columns. So, the upstream webhook is expected to receive those values in the payload.

{
    "version": "<version-integer>",
    "role": "<role-name>",
    "session_variables": {
        "x-hasura-user-id": "<session-user-id>",
        "x-hasura-user-name": "<session-user-name>"
    },
    "data": {
      "input": 
        [
          JSON-fied <model_name>_updates!,
          "pk_columns": JSON-fied <model_name>_pk_columns_input! (only included for update_<mode_name>_by_pk)
        ]
    }
}
  • version: An integer version serves to indicate the request format. Whenever a breaking update occurs in the request payload, the version will be incremented. The initial version is set to 1.
  • role: Hasura session role on which permissions are enforced.
  • session_variables: Session variables that aid in enforcing permissions. Variable names always starts with x-hasura-*.
  • data.updates: List of the multiple updates to run. The structure of this field will be similar to the JSONified structure of the <model_name>_updates! graphql type. If it is an update mutation by primary key, then it will also contain the <model_name>_pk_columns_input!

Response

  1. Successful Response

The HTTP validation URL should return a 200 status code to represent successful validation.

200 OK
  1. Unsuccessful Response

The HTTP validation URL should return a optional JSON object with 400 status code to represent failed validation. The object should contain message field whose value is a string and this message is forwarded to client.

If no JSON object is returned then no message is forwarded to client.

400 BAD REQUEST

{
    "message": "Phone Number Invalid"
}

When an unexpected response format is received, Hasura raises internal exception.

When an unexpected response format is received, Hasura raises internal exception.

Examples

  1. [Single Update Condition] Check if name is a valid string

Consider the following sample mutation query:

mutation update_author {
  update_author(where: { id: { _eq: 3 } }, _set: { name: "Jane" }) {
    affected_rows
  }
}

The arguments used to update author model i.e. where and _set are sent to the validate_input url of author update permission. The url can check the email value. If the url returns success, then the mutation proceeds else the error from the url is forwarded.

The sample payload the validation URL of author model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
      "input": [
          {
              "where": {"id": {"_eq": 3}},
              "_set": {"name": "Jane"}
          }
      ]
    }
}
  1. [Multiple Update Condition] Check if the number of update conditions are less than 50

Consider the following sample mutation query:

mutation update_many_articles {
  update_article_many (
    updates: [
      {
        where: {rating: {_lte: 1}},
        _set: {is_published: false}
      },
      {
       where: {rating: {_gte: 4}},
        _set: {is_published: true}
      }
    ]
  ) {
    affected_rows
  }
}

The arguments used to update articles model i.e. the list of update arguments (where and _set) are sent to the validate_input url of author update permission. The url can check the number of update arguments. If the url returns success, then the mutation proceeds else the error from the url is forwarded.

The sample payload the validation URL of article model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
        "input": [
            {
              "where": {"rating": {"_lte": 1}},
              "_set": {"is_published": false}
            },
            {
              "where": {"rating": {"_gte": 4}},
              "_set": {"is_published": true}
            }
        ]
    }
}
  1. [Update condition by Primary Key] Check if name is a valid string

Consider the following sample mutation query:

mutation update_author {
  update_author_by_pk(pk_columns: {id: 3}, _set: { name: "Jane" }) {
    affected_rows
  }
}

The sample payload the validation URL of author model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
      "input": [
          {
              "pk_columns": {"id": 3},
              "_set": {"name": "Jane"}
          }
      ]
    }
}

Delete mutations

Behaviour

When a delete mutation comes in with a role, the following steps are performed:

  1. If there is a validate_input permission on a table, then any delete arguments targeting that table are sent to the url.
  2. If the handlers validates the delete arguments , then the request proceeds. A transaction with the database will only be started after the validation is over and successful.
  3. If any url invalidates the delete arfuments, then the request aborts. An error message from the url can also be forwarded to the client.

Consider the following sample mutation query:

mutation delete_articles {
  delete_article(where: { author: { id: { _eq: 7 } } }) {
    affected_rows
    returning {
      id
    }
  }
}

The mentioned mutation targets the articles table and wants to delete the rows present in the table which satisfy the delete condition. Assuming that the validate_input permission is defined for the table, the validation process unfolds as follows:

  • The validation webhook specified for the author table is contacted first, including the delete arguments.
  • If the above webhook calls result in successful validation, a database transaction is initiated to delete the rows of the respective tables.

Webhook specification

Request

Consider the following sample mutation query:

mutation delete_articles {
  delete_article(where: { author: { id: { _eq: 7 } } }) {
    affected_rows
    returning {
      id
    }
  }
}

The user may want to validate the input values in the where clause and pk_columns. So, the upstream webhook is expected to receive those values in the payload.

{
    "version": "<version-integer>",
    "role": "<role-name>",
    "session_variables": {
        "x-hasura-user-id": "<session-user-id>",
        "x-hasura-user-name": "<session-user-name>"
    },
    "data": {
      "input": 
        {
            JSON-fied <model_name>_bool_exp!,
            "pk_columns": JSON-fied <model_name>_pk_columns_input! (only included for delete_<mode_name>_by_pk)
        }
    }
}
  • version: An integer version serves to indicate the request format. Whenever a breaking update occurs in the request payload, the version will be incremented. The initial version is set to 1.
  • role: Hasura session role on which permissions are enforced.
  • session_variables: Session variables that aid in enforcing permissions. Variable names always starts with x-hasura-*.
  • data.delete: The delete condition. The structure of this field will be similar to the JSONified structure of the <model_name>_bool_exp! graphql type. If it is an delete mutation by primary key, then it will also contain the <model_name>_pk_columns_input!

Response

  1. Successful Response

The HTTP validation URL should return a 200 status code to represent successful validation.

200 OK
  1. Unsucessful Response

The HTTP validation URL should return a optional JSON object with 400 status code to represent failed validation. The object should contain message field whose value is a string and this message is forwarded to client.

If no JSON object is returned then no message is forwarded to client.

400 BAD REQUEST

{
    "message": "Phone number invalid"
}

When an unexpected response format is received, Hasura raises internal exception

Examples

  1. [Delete Condition] Check if id is a valid number

Consider the following sample mutation query:

mutation delete_articles {
  delete_article(where: { author: { id: { _eq: 7 } } }) {
    affected_rows
    returning {
      id
    }
  }
}

The arguments used to delete article model i.e. where and _set are sent to the validate_input url of author update permission. The url can check the email value. If the url returns success, then the mutation proceeds else the error from the url is forwarded.

The sample payload the validation URL of author model receives is:

{
    "version": 1,
    "role": "user",
    "session_variables": {
        "x-hasura-role": "user"
    },
    "data": {
      "input": [
          {
              "where": {"id": {"_eq": 3}}
          }
      ]
    }
}

Enhancements

  1. Consider validate_input at a column-level. The definition spec would differ for this.

  2. We can also consider warning semantics for validate_input url where the validation is success but with warnings.

  3. Facilitate users to specify a list of fields that could be sent to url for validation. Grants user control over determining which fields are sent for validation.

validate_input:
  type: http
  defintion: <DEFINITION>
  fields:
  - column_1
  - column_2
  - relationship_1
  - relationship_2