■ ■ ■ ■ ■ ■
v3/docs/roles-and-annotations.md
| 1 | + | # Roles / namespaces / annotations in `v3-engine` |
| 2 | + | |
| 3 | + | In V2 we created a schema per role. |
| 4 | + | |
| 5 | + | In V3 we create one big schema at build time, where some nodes are annotated with either |
| 6 | + | `generic` info, or `namespaced` info that only applies to specific roles. |
| 7 | + | |
| 8 | + | The types for these annotations come from the `lang-graphql` crate, where they |
| 9 | + | are associated types on the `SchemaContext` trait. |
| 10 | + | |
| 11 | + | This example used in tests for `lang-graphql` shows a schema with no |
| 12 | + | annotations. It uses a `Namespace` type to differentiate different scopes, |
| 13 | + | however since it has no annotations this won't do anything meaningful: |
| 14 | + | |
| 15 | + | ```rust |
| 16 | + | impl SchemaContext for SDL { |
| 17 | + | type Namespace = Namespace; |
| 18 | + | type GenericNodeInfo = (); |
| 19 | + | type NamespacedNodeInfo = (); |
| 20 | + | ``` |
| 21 | + | |
| 22 | + | The `engine` itself uses the `GDS` type, and so we can use the following annotations: |
| 23 | + | |
| 24 | + | ```rust |
| 25 | + | impl gql_schema::SchemaContext for GDS { |
| 26 | + | type Namespace = Role; |
| 27 | + | type GenericNodeInfo = types::Annotation; |
| 28 | + | type NamespacedNodeInfo = Option<types::NamespaceAnnotation>; |
| 29 | + | ``` |
| 30 | + | |
| 31 | + | Note that our `Namespace` type is `Role` - this means that the engine attached |
| 32 | + | useful information per role, so we can add select permissions for a field that |
| 33 | + | only apply to the `user-1` role, for instance. |
| 34 | + | |
| 35 | + | (incidentally, there is a comment on the `SchemaContext` trait suggesting `Namespace` is |
| 36 | + | renamed to `Scope` or `Role` - this seems like a useful move to make all this |
| 37 | + | clearer, personally `Namespace` makes me think about different subgraphs or |
| 38 | + | something) |
| 39 | + | |
| 40 | + | ## What is an annotation then, concretely? |
| 41 | + | |
| 42 | + | The change I have recently been working on is to add preset arguments to |
| 43 | + | commands. This means that given a `delete_user` command with a `user_id: Int` |
| 44 | + | argument, we can preset the value for certain roles. |
| 45 | + | |
| 46 | + | For instance, we might want a `user-1` role to only be able to delete |
| 47 | + | themselves, so we'd preset `user_id` with `{ "sessionVariable": |
| 48 | + | "x-hasura-user-id" }`. |
| 49 | + | |
| 50 | + | Previous to this change, when building the schema for the Command, we'd use |
| 51 | + | `builder.allow_all_namespaced` to create it, like |
| 52 | + | |
| 53 | + | ```rust |
| 54 | + | builder.allow_all_namespace(command_schema_stuff, None) |
| 55 | + | ``` |
| 56 | + | |
| 57 | + | Instead, for our example, we'd use `conditional_namespaced` like this (excuse my pseudo-Rust): |
| 58 | + | |
| 59 | + | ```rust |
| 60 | + | let role_annotations = HashMap::new(); |
| 61 | + | |
| 62 | + | role_annotations.insert("user-1", Some(ArgumentPresets { "user_id": |
| 63 | + | "x-hasura-role-id" })); |
| 64 | + | |
| 65 | + | role_annotations.insert("admin", None); |
| 66 | + | |
| 67 | + | builder.conditional_namespaced(command_schema_stuff, role_annotations) |
| 68 | + | ``` |
| 69 | + | |
| 70 | + | Here, we've added an annotation for `user-1` with some useful information we |
| 71 | + | can use later when receiving and running queries. We've also added a `None` |
| 72 | + | annotation for `admin` - this means there'll be no useful information later, |
| 73 | + | but we're still signalling that we want this `Command` to work for `admin` |
| 74 | + | users. If we had a `user-2` role in the schema, they wouldn't be able to use |
| 75 | + | the command. |
| 76 | + | |
| 77 | + | ## Removing items from the GraphQL schema, per role |
| 78 | + | |
| 79 | + | By adding or omitting keys from the `role_annotations` above, we've made our |
| 80 | + | command appear or disappear from the GraphQL schema. This "snipping" as such, |
| 81 | + | happens in the `normalize_request` function in `lang-graphql`. |
| 82 | + | |
| 83 | + | The important thing to know here is that `lang-graphql` code does not know |
| 84 | + | about `GDS` or our `NamespaceAnnotation` type. All it has to act on is whether |
| 85 | + | a `Role` (or `Namespace`, to it's eyes) has a key in any namespaced annotations |
| 86 | + | or not. Because we're using associated types the contents are "protected" from |
| 87 | + | `lang-graphql` and so it can't peek inside. |
| 88 | + | |
| 89 | + | We're going to want the `user_id` argument to disappear from `user-1`'s schema |
| 90 | + | - we do this by using `conditional_namespaced` when contructing schema for the |
| 91 | + | `user_id` command argument itself: |
| 92 | + | |
| 93 | + | ```rust |
| 94 | + | let role_annotations = HashMap::new(); |
| 95 | + | |
| 96 | + | // insert an empty annotation for `admin` to make sure the argument remains in |
| 97 | + | the schema |
| 98 | + | role_annotations.insert("admin", None); |
| 99 | + | |
| 100 | + | // don't add one for `user-1`, so it disappears from the schema (as it has been |
| 101 | + | replaced with the preset value) |
| 102 | + | builder.conditional_namespaced(command_argument_schema_stuff, role_annotations) |
| 103 | + | ``` |
| 104 | + | |
| 105 | + | ## Reading the annotations at request time |
| 106 | + | |
| 107 | + | Everything before here happens at "compile time" for the engine, so we do it |
| 108 | + | all once at startup and it remains static for the lifetime of the application. |
| 109 | + | |
| 110 | + | At some point we are going to need to [serve a |
| 111 | + | request](https://github.com/hasura/v3-engine/blob/main/engine/src/execute.rs#L170) though. The steps are as |
| 112 | + | follows: |
| 113 | + | |
| 114 | + | - Receive request |
| 115 | + | - Parse into a GraphQL query |
| 116 | + | - Normalize the query |
| 117 | + | - Generate IR |
| 118 | + | - Construct a query plan |
| 119 | + | - Execute / explain the query plan |
| 120 | + | |
| 121 | + | The `normalize` step was mentioned earlier - it takes place in `lang_graphql` |
| 122 | + | and combines the query with the role information to remove all irrelevant |
| 123 | + | namespace information from the query, and snip any parts of the schema that the |
| 124 | + | current role can't see. |
| 125 | + | |
| 126 | + | Generally the helpful place for our annotations is in generating IR |
| 127 | + | (intermediate representation, compiler fans). For our commands change, we can |
| 128 | + | look up the `ArgumentPresets` we stored earlier like |
| 129 | + | [this](https://github.com/hasura/v3-engine/pull/340/files#diff-f01744b02938317df22c7bc991717ae20a397f623c387332f103a30d1c0d2dc9R104). |
| 130 | + | |
| 131 | + | ```rust |
| 132 | + | match field_call.info.namespaced { |
| 133 | + | None => {} |
| 134 | + | Some(NamespaceAnnotation::ArgumentPresets(argument_presets)) => { |
| 135 | + | // use `argument_presets` for current role to generate IR |
| 136 | + | ... |
| 137 | + | } |
| 138 | + | } |
| 139 | + | ``` |
| 140 | + | |
| 141 | + | The IR will then be created from a mixture of the schema for the user's role, |
| 142 | + | and any arguments etc from their request. |
| 143 | + | |
| 144 | + | |
| 145 | + | |