graphql-engine/server/src-lib/Hasura/GraphQL/Schema/Action.hs
Karthikeyan Chinnakonda 92026b769f [Preview] Inherited roles for postgres read queries
fixes #3868

docker image - `hasura/graphql-engine:inherited-roles-preview-48b73a2de`

Note:

To be able to use the inherited roles feature, the graphql-engine should be started with the env variable `HASURA_GRAPHQL_EXPERIMENTAL_FEATURES` set to `inherited_roles`.

Introduction
------------

This PR implements the idea of multiple roles as presented in this [paper](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/FGALanguageICDE07.pdf). The multiple roles feature in this PR can be used via inherited roles. An inherited role is a role which can be created by combining multiple singular roles. For example, if there are two roles `author` and `editor` configured in the graphql-engine, then we can create a inherited role with the name of `combined_author_editor` role which will combine the select permissions of the `author` and `editor` roles and then make GraphQL queries using the `combined_author_editor`.

How are select permissions of different roles are combined?
------------------------------------------------------------

A select permission includes 5 things:

1. Columns accessible to the role
2. Row selection filter
3. Limit
4. Allow aggregation
5. Scalar computed fields accessible to the role

 Suppose there are two roles, `role1` gives access to the `address` column with row filter `P1` and `role2` gives access to both the `address` and the `phone` column with row filter `P2` and we create a new role `combined_roles` which combines `role1` and `role2`.

Let's say the following GraphQL query is queried with the `combined_roles` role.

```graphql
query {
   employees {
     address
     phone
   }
}
```

This will translate to the following SQL query:

```sql

 select
    (case when (P1 or P2) then address else null end) as address,
    (case when P2 then phone else null end) as phone
 from employee
 where (P1 or P2)
```

The other parameters of the select permission will be combined in the following manner:

1. Limit - Minimum of the limits will be the limit of the inherited role
2. Allow aggregations - If any of the role allows aggregation, then the inherited role will allow aggregation
3. Scalar computed fields - same as table column fields, as in the above example

APIs for inherited roles:
----------------------

1. `add_inherited_role`

`add_inherited_role` is the [metadata API](https://hasura.io/docs/1.0/graphql/core/api-reference/index.html#schema-metadata-api) to create a new inherited role. It accepts two arguments

`role_name`: the name of the inherited role to be added (String)
`role_set`: list of roles that need to be combined (Array of Strings)

Example:

```json
{
  "type": "add_inherited_role",
  "args": {
      "role_name":"combined_user",
      "role_set":[
          "user",
          "user1"
      ]
  }
}
```

After adding the inherited role, the inherited role can be used like single roles like earlier

Note:

An inherited role can only be created with non-inherited/singular roles.

2. `drop_inherited_role`

The `drop_inherited_role` API accepts the name of the inherited role and drops it from the metadata. It accepts a single argument:

`role_name`: name of the inherited role to be dropped

Example:

```json

{
  "type": "drop_inherited_role",
  "args": {
      "role_name":"combined_user"
  }
}
```

Metadata
---------

The derived roles metadata will be included under the `experimental_features` key while exporting the metadata.

```json
{
  "experimental_features": {
    "derived_roles": [
      {
        "role_name": "manager_is_employee_too",
        "role_set": [
          "employee",
          "manager"
        ]
      }
    ]
  }
}
```

Scope
------

Only postgres queries and subscriptions are supported in this PR.

Important points:
-----------------

1. All columns exposed to an inherited role will be marked as `nullable`, this is done so that cell value nullification can be done.

TODOs
-------

- [ ] Tests
   - [ ] Test a GraphQL query running with a inherited role without enabling inherited roles in experimental features
   - [] Tests for aggregate queries, limit, computed fields, functions, subscriptions (?)
   - [ ] Introspection test with a inherited role (nullability changes in a inherited role)
- [ ] Docs
- [ ] Changelog

Co-authored-by: Vamshi Surabhi <6562944+0x777@users.noreply.github.com>
GitOrigin-RevId: 3b8ee1e11f5ceca80fe294f8c074d42fbccfec63
2021-03-08 11:15:10 +00:00

360 lines
16 KiB
Haskell

module Hasura.GraphQL.Schema.Action
( actionExecute
, actionAsyncMutation
, actionAsyncQuery
) where
import Hasura.Prelude
import qualified Data.Aeson as J
import qualified Data.HashMap.Strict as Map
import qualified Language.GraphQL.Draft.Syntax as G
import Data.Has
import Data.Text.Extended
import Data.Text.NonEmpty
import qualified Hasura.GraphQL.Parser as P
import qualified Hasura.GraphQL.Parser.Internal.Parser as P
import qualified Hasura.RQL.DML.Internal as RQL
import qualified Hasura.RQL.IR.Select as RQL
import Hasura.Backends.Postgres.SQL.Types
import Hasura.GraphQL.Parser (FieldParser, InputFieldsParser, Kind (..),
Parser, UnpreparedValue (..))
import Hasura.GraphQL.Parser.Class
import Hasura.GraphQL.Schema.Backend
import Hasura.GraphQL.Schema.Common
import Hasura.GraphQL.Schema.Select
import Hasura.RQL.Types
import Hasura.Session
-- | actionExecute is used to execute either a query action or a synchronous
-- mutation action. A query action or a synchronous mutation action accepts
-- the field name and input arguments and a selectionset. The
-- input argument and selectionset types are defined by the user.
--
--
-- > action_name(action_input_arguments) {
-- > col1: col1_type
-- > col2: col2_type
-- > }
actionExecute
:: forall m n r
. ( BackendSchema 'Postgres
, MonadSchema n m
, MonadTableInfo r m
, MonadRole r m
, Has QueryContext r
, Has (BackendExtension 'Postgres) r
)
=> NonObjectTypeMap
-> ActionInfo
-> m (Maybe (FieldParser n (AnnActionExecution 'Postgres (UnpreparedValue 'Postgres))))
actionExecute nonObjectTypeMap actionInfo = runMaybeT do
roleName <- askRoleName
guard $ (roleName == adminRoleName || roleName `Map.member` permissions)
let fieldName = unActionName actionName
description = G.Description <$> comment
inputArguments <- lift $ actionInputArguments nonObjectTypeMap $ _adArguments definition
selectionSet <- lift $ actionOutputFields outputObject
stringifyNum <- asks $ qcStringifyNum . getter
pure $ P.subselection fieldName description inputArguments selectionSet
<&> \(argsJson, fields) -> AnnActionExecution
{ _aaeName = actionName
, _aaeFields = fields
, _aaePayload = argsJson
, _aaeOutputType = _adOutputType definition
, _aaeOutputFields = getActionOutputFields outputObject
, _aaeDefinitionList = mkDefinitionList outputObject
, _aaeWebhook = _adHandler definition
, _aaeHeaders = _adHeaders definition
, _aaeForwardClientHeaders = _adForwardClientHeaders definition
, _aaeStrfyNum = stringifyNum
, _aaeTimeOut = _adTimeout definition
, _aaeSource = getActionSourceInfo (_aiOutputObject actionInfo)
}
where
ActionInfo actionName outputObject definition permissions comment = actionInfo
-- | actionAsyncMutation is used to execute a asynchronous mutation action. An
-- asynchronous action expects the field name and the input arguments to the
-- action. A selectionset is *not* expected. An action ID (UUID) will be
-- returned after performing the action
--
-- > action_name(action_input_arguments)
actionAsyncMutation
:: forall m n r. (MonadSchema n m, MonadTableInfo r m, MonadRole r m)
=> NonObjectTypeMap
-> ActionInfo
-> m (Maybe (FieldParser n AnnActionMutationAsync))
actionAsyncMutation nonObjectTypeMap actionInfo = runMaybeT do
roleName <- lift askRoleName
guard $ roleName == adminRoleName || roleName `Map.member` permissions
inputArguments <- lift $ actionInputArguments nonObjectTypeMap $ _adArguments definition
let fieldName = unActionName actionName
description = G.Description <$> comment
pure $ P.selection fieldName description inputArguments actionIdParser
<&> AnnActionMutationAsync actionName
where
ActionInfo actionName _ definition permissions comment = actionInfo
-- | actionAsyncQuery is used to query/subscribe to the result of an
-- asynchronous mutation action. The only input argument to an
-- asynchronous mutation action is the action ID (UUID) and a selection
-- set is expected, the selection set contains 4 fields namely 'id',
-- 'created_at','errors' and 'output'. The result of the action can be queried
-- through the 'output' field.
--
-- > action_name (id: UUID!) {
-- > id: UUID!
-- > created_at: timestampz!
-- > errors: JSON
-- > output: user_defined_type!
-- > }
actionAsyncQuery
:: forall m n r
. ( BackendSchema 'Postgres
, MonadSchema n m
, MonadTableInfo r m
, MonadRole r m
, Has QueryContext r
, Has (BackendExtension 'Postgres) r
)
=> ActionInfo
-> m (Maybe (FieldParser n (AnnActionAsyncQuery 'Postgres (UnpreparedValue 'Postgres))))
actionAsyncQuery actionInfo = runMaybeT do
roleName <- askRoleName
guard $ roleName == adminRoleName || roleName `Map.member` permissions
actionOutputParser <- lift $ actionOutputFields outputObject
createdAtFieldParser <-
lift $ columnParser @'Postgres (ColumnScalar PGTimeStampTZ) (G.Nullability False)
errorsFieldParser <-
lift $ columnParser @'Postgres (ColumnScalar PGJSON) (G.Nullability True)
let fieldName = unActionName actionName
description = G.Description <$> comment
actionIdInputField =
P.field idFieldName (Just idFieldDescription) actionIdParser
allFieldParsers =
let idField = P.selection_ idFieldName (Just idFieldDescription) actionIdParser $> AsyncId
createdAtField = P.selection_ $$(G.litName "created_at")
(Just "the time at which this action was created")
createdAtFieldParser $> AsyncCreatedAt
errorsField = P.selection_ $$(G.litName "errors")
(Just "errors related to the invocation")
errorsFieldParser $> AsyncErrors
outputField = P.subselection_ $$(G.litName "output")
(Just "the output fields of this action")
actionOutputParser <&> AsyncOutput
in [idField, createdAtField, errorsField, outputField]
selectionSet =
let outputTypeName = unActionName actionName
desc = G.Description $ "fields of action: " <>> actionName
in P.selectionSet outputTypeName (Just desc) allFieldParsers
<&> parsedSelectionsToFields AsyncTypename
stringifyNum <- asks $ qcStringifyNum . getter
pure $ P.subselection fieldName description actionIdInputField selectionSet
<&> \(idArg, fields) -> AnnActionAsyncQuery
{ _aaaqName = actionName
, _aaaqActionId = idArg
, _aaaqOutputType = _adOutputType definition
, _aaaqFields = fields
, _aaaqDefinitionList = mkDefinitionList outputObject
, _aaaqStringifyNum = stringifyNum
, _aaaqSource = getActionSourceInfo (_aiOutputObject actionInfo)
}
where
ActionInfo actionName outputObject definition permissions comment = actionInfo
idFieldName = $$(G.litName "id")
idFieldDescription = "the unique id of an action"
-- | Async action's unique id
actionIdParser
:: MonadParse n => Parser 'Both n ActionId
actionIdParser = ActionId <$> P.uuid
actionOutputFields
:: forall m n r
. ( BackendSchema 'Postgres
, MonadSchema n m
, MonadTableInfo r m
, MonadRole r m
, Has QueryContext r
, Has (BackendExtension 'Postgres) r
)
=> AnnotatedObjectType
-> m (Parser 'Output n (RQL.AnnFieldsG 'Postgres (UnpreparedValue 'Postgres)))
actionOutputFields annotatedObject = do
let outputObject = _aotDefinition annotatedObject
scalarOrEnumFields = map scalarOrEnumFieldParser $ toList $ _otdFields outputObject
relationshipFields <- forM (_otdRelationships outputObject) $ traverse relationshipFieldParser
let allFieldParsers = scalarOrEnumFields <>
maybe [] (concat . catMaybes . toList) relationshipFields
outputTypeName = unObjectTypeName $ _otdName outputObject
outputTypeDescription = _otdDescription outputObject
pure $ P.selectionSet outputTypeName outputTypeDescription allFieldParsers
<&> parsedSelectionsToFields RQL.AFExpression
where
scalarOrEnumFieldParser
:: ObjectFieldDefinition (G.GType, AnnotatedObjectFieldType)
-> FieldParser n (RQL.AnnFieldG 'Postgres (UnpreparedValue 'Postgres))
scalarOrEnumFieldParser (ObjectFieldDefinition name _ description ty) =
let (gType, objectFieldType) = ty
fieldName = unObjectFieldName name
-- FIXME? (from master)
pgColumnInfo = ColumnInfo (unsafePGCol $ G.unName fieldName)
fieldName 0 (ColumnScalar PGJSON) (G.isNullable gType) Nothing
fieldParser = case objectFieldType of
AOFTScalar def -> customScalarParser def
AOFTEnum def -> customEnumParser def
in bool P.nonNullableField id (G.isNullable gType) $
P.selection_ (unObjectFieldName name) description fieldParser
$> RQL.mkAnnColumnField pgColumnInfo Nothing Nothing
relationshipFieldParser
:: TypeRelationship (TableInfo 'Postgres) (ColumnInfo 'Postgres)
-> m (Maybe [FieldParser n (RQL.AnnFieldG 'Postgres (UnpreparedValue 'Postgres))])
relationshipFieldParser (TypeRelationship relName relType _ tableInfo fieldMapping) = runMaybeT do
let tableName = _tciName $ _tiCoreInfo tableInfo
fieldName = unRelationshipName relName
tableRelName = RelName $ mkNonEmptyTextUnsafe $ G.unName fieldName
columnMapping = Map.fromList $ do
(k, v) <- Map.toList fieldMapping
pure (unsafePGCol $ G.unName $ unObjectFieldName k, pgiColumn v)
roleName <- lift askRoleName
tablePerms <- hoistMaybe $ RQL.getPermInfoMaybe roleName PASelect tableInfo
case relType of
ObjRel -> do
let desc = Just $ G.Description "An object relationship"
selectionSetParser <- lift $ tableSelectionSet tableName tablePerms
pure $ pure $ P.nonNullableField $
P.subselection_ fieldName desc selectionSetParser
<&> \fields -> RQL.AFObjectRelation $ RQL.AnnRelationSelectG tableRelName columnMapping $
RQL.AnnObjectSelectG fields tableName $
fmapAnnBoolExp partialSQLExpToUnpreparedValue $ spiFilter tablePerms
ArrRel -> do
let desc = Just $ G.Description "An array relationship"
otherTableParser <- lift $ selectTable tableName fieldName desc tablePerms
let arrayRelField = otherTableParser <&> \selectExp -> RQL.AFArrayRelation $
RQL.ASSimple $ RQL.AnnRelationSelectG tableRelName columnMapping selectExp
relAggFieldName = fieldName <> $$(G.litName "_aggregate")
relAggDesc = Just $ G.Description "An aggregate relationship"
tableAggField <- lift $ selectTableAggregate tableName relAggFieldName relAggDesc tablePerms
pure $ catMaybes [ Just arrayRelField
, fmap (RQL.AFArrayRelation . RQL.ASAggregate . RQL.AnnRelationSelectG tableRelName columnMapping) <$> tableAggField
]
mkDefinitionList :: AnnotatedObjectType -> [(PGCol, ScalarType 'Postgres)]
mkDefinitionList AnnotatedObjectType{..} =
flip map (toList _otdFields) $ \ObjectFieldDefinition{..} ->
(unsafePGCol . G.unName . unObjectFieldName $ _ofdName,) $
case Map.lookup _ofdName fieldReferences of
Nothing -> fieldTypeToScalarType $ snd _ofdType
Just columnInfo -> unsafePGColumnToBackend $ pgiType columnInfo
where
ObjectTypeDefinition{..} = _aotDefinition
fieldReferences =
Map.unions $ map _trFieldMapping $ maybe [] toList _otdRelationships
actionInputArguments
:: forall m n r. (MonadSchema n m, MonadTableInfo r m)
=> NonObjectTypeMap
-> [ArgumentDefinition (G.GType, NonObjectCustomType)]
-> m (InputFieldsParser n J.Value)
actionInputArguments nonObjectTypeMap arguments = do
argumentParsers <- for arguments $ \argument -> do
let ArgumentDefinition argumentName (gType, nonObjectType) argumentDescription = argument
name = unArgumentName argumentName
(name,) <$> argumentParser name argumentDescription gType nonObjectType
pure $ J.Object <$> inputFieldsToObject argumentParsers
where
inputFieldsToObject
:: [(G.Name, InputFieldsParser n (Maybe J.Value))]
-> InputFieldsParser n J.Object
inputFieldsToObject inputFields =
let mkTuple (name, parser) = fmap (G.unName name,) <$> parser
in Map.fromList . catMaybes <$> traverse mkTuple inputFields
argumentParser
:: G.Name
-> Maybe G.Description
-> G.GType
-> NonObjectCustomType
-> m (InputFieldsParser n (Maybe J.Value))
argumentParser name description gType = \case
NOCTScalar def -> pure $ mkArgumentInputFieldParser name description gType $ customScalarParser def
NOCTEnum def -> pure $ mkArgumentInputFieldParser name description gType $ customEnumParser def
NOCTInputObject def -> do
let InputObjectTypeDefinition typeName objectDescription inputFields = def
objectName = unInputObjectTypeName typeName
inputFieldsParsers <- forM (toList inputFields) $ \inputField -> do
let InputObjectFieldName fieldName = _iofdName inputField
GraphQLType fieldType = _iofdType inputField
nonObjectFieldType <-
onNothing (Map.lookup (G.getBaseType fieldType) nonObjectTypeMap) $
throw500 "object type for a field found in custom input object type"
(fieldName,) <$> argumentParser fieldName (_iofdDescription inputField) fieldType nonObjectFieldType
pure $ mkArgumentInputFieldParser name description gType $
P.object objectName objectDescription $
J.Object <$> inputFieldsToObject inputFieldsParsers
mkArgumentInputFieldParser
:: forall m k. (MonadParse m, 'Input P.<: k)
=> G.Name
-> Maybe G.Description
-> G.GType
-> Parser k m J.Value
-> InputFieldsParser m (Maybe J.Value)
mkArgumentInputFieldParser name description gType parser =
if G.isNullable gType
then P.fieldOptional name description modifiedParser
else Just <$> P.field name description modifiedParser
where
modifiedParser = parserModifier gType parser
parserModifier
:: G.GType -> Parser k m J.Value -> Parser k m J.Value
parserModifier = \case
G.TypeNamed nullable _ -> nullableModifier nullable
G.TypeList nullable ty ->
nullableModifier nullable . fmap J.toJSON . P.list . parserModifier ty
where
nullableModifier =
bool (fmap J.toJSON) (fmap J.toJSON . P.nullable) . G.unNullability
customScalarParser
:: MonadParse m
=> AnnotatedScalarType -> Parser 'Both m J.Value
customScalarParser = \case
ASTCustom ScalarTypeDefinition{..} ->
if | _stdName == idScalar -> J.toJSON <$> P.identifier
| _stdName == intScalar -> J.toJSON <$> P.int
| _stdName == floatScalar -> J.toJSON <$> P.float
| _stdName == stringScalar -> J.toJSON <$> P.string
| _stdName == boolScalar -> J.toJSON <$> P.boolean
| otherwise -> P.namedJSON _stdName _stdDescription
ASTReusedScalar name pgScalarType ->
let schemaType = P.NonNullable $ P.TNamed $ P.mkDefinition name Nothing P.TIScalar
in P.Parser
{ pType = schemaType
, pParser = P.valueToJSON (P.toGraphQLType schemaType) >=>
either (parseErrorWith ParseFailed . qeError)
(pure . scalarValueToJSON) . parseScalarValue pgScalarType
}
customEnumParser
:: MonadParse m
=> EnumTypeDefinition -> Parser 'Both m J.Value
customEnumParser (EnumTypeDefinition typeName description enumValues) =
let enumName = unEnumTypeName typeName
enumValueDefinitions = enumValues <&> \enumValue ->
let valueName = G.unEnumValue $ _evdValue enumValue
in (, J.toJSON valueName) $ P.mkDefinition valueName
(_evdDescription enumValue) P.EnumValueInfo
in P.enum enumName description enumValueDefinitions