mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-17 12:31:52 +03:00
92026b769f
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
251 lines
10 KiB
Haskell
251 lines
10 KiB
Haskell
module Hasura.GraphQL.Execute.RemoteJoin
|
|
( RemoteJoins
|
|
, RemoteJoinMap
|
|
, FieldPath(..)
|
|
, appendPath
|
|
, getRemoteJoins
|
|
, getRemoteJoinsSelect
|
|
, getRemoteJoinsAggregateSelect
|
|
, getRemoteJoinsConnectionSelect
|
|
, getRemoteJoinsMutationOutput
|
|
) where
|
|
|
|
import Hasura.Prelude
|
|
|
|
import qualified Data.HashMap.Strict as Map
|
|
import qualified Data.HashSet as HS
|
|
import qualified Data.List.NonEmpty as NE
|
|
|
|
import Control.Lens
|
|
|
|
import Hasura.GraphQL.Context
|
|
import Hasura.RQL.IR.RemoteJoin
|
|
import Hasura.RQL.IR.Returning
|
|
import Hasura.RQL.IR.Select
|
|
import Hasura.RQL.Types
|
|
|
|
{- Note: [Remote Joins Architecture]
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
Unparsed Incoming GraphQL +------------------------------+
|
|
--------------------------> | Parsing of the GraphQL query |-----+
|
|
+------------------------------+ |
|
|
| DB Query and remote joins (if any)
|
|
|
|
|
V
|
|
+----------------------------------+ SQL query response +----------------------------+
|
|
| Traverse the DB response to | <------------------- | Execution of the DB query |
|
|
| get the values of the arguments | +----------------------------+
|
|
| of the remote field |
|
|
+----------------------------------+
|
|
|
|
|
| Remote field arguments
|
|
V
|
|
+--------------------------+ Remote schema response +----------------------------------------+
|
|
| Query the remote schema | ------------------------> | Replace the remote join fields in |
|
|
| with the remote field | | the SQL query response (JSON) with |
|
|
| arguments to the remote | | the response obtained from the remote |
|
|
| field configured in the | | schema at appropriate places. |
|
|
| remote join. | +----------------------------------------+
|
|
+--------------------------+
|
|
-}
|
|
|
|
|
|
|
|
-- remote joins info
|
|
|
|
-- | Path to the remote join field in query response JSON from Postgres.
|
|
newtype FieldPath = FieldPath {unFieldPath :: [FieldName]}
|
|
deriving (Show, Eq, Semigroup, Monoid, Hashable)
|
|
|
|
appendPath :: FieldName -> FieldPath -> FieldPath
|
|
appendPath fieldName = FieldPath . (<> [fieldName]) . unFieldPath
|
|
|
|
|
|
type RemoteJoins b = NE.NonEmpty (FieldPath, NE.NonEmpty (RemoteJoin b))
|
|
type RemoteJoinMap b = Map.HashMap FieldPath (NE.NonEmpty (RemoteJoin b))
|
|
|
|
|
|
-- extract remote joins out of a root field
|
|
|
|
getRemoteJoins
|
|
:: Backend b
|
|
=> QueryDB b u
|
|
-> Maybe (RemoteJoins b)
|
|
getRemoteJoins = \case
|
|
QDBMultipleRows s -> snd $ getRemoteJoinsSelect s
|
|
QDBSingleRow s -> snd $ getRemoteJoinsSelect s
|
|
QDBAggregation s -> snd $ getRemoteJoinsAggregateSelect s
|
|
QDBConnection s -> snd $ getRemoteJoinsConnectionSelect s
|
|
|
|
-- | Traverse through 'AnnSimpleSel' and collect remote join fields (if any).
|
|
getRemoteJoinsSelect
|
|
:: Backend b
|
|
=> AnnSimpleSelG b u
|
|
-> (AnnSimpleSelG b u, Maybe (RemoteJoins b))
|
|
getRemoteJoinsSelect =
|
|
second mapToNonEmpty . flip runState mempty . transformSelect mempty
|
|
|
|
-- | Traverse through @'AnnAggregateSelect' and collect remote join fields (if any).
|
|
getRemoteJoinsAggregateSelect
|
|
:: Backend b
|
|
=> AnnAggregateSelectG b u
|
|
-> (AnnAggregateSelectG b u, Maybe (RemoteJoins b))
|
|
getRemoteJoinsAggregateSelect =
|
|
second mapToNonEmpty . flip runState mempty . transformAggregateSelect mempty
|
|
|
|
-- | Traverse through @'ConnectionSelect' and collect remote join fields (if any).
|
|
getRemoteJoinsConnectionSelect
|
|
:: Backend b
|
|
=> ConnectionSelect b u
|
|
-> (ConnectionSelect b u, Maybe (RemoteJoins b))
|
|
getRemoteJoinsConnectionSelect =
|
|
second mapToNonEmpty . flip runState mempty . transformConnectionSelect mempty
|
|
|
|
-- | Traverse through 'MutationOutput' and collect remote join fields (if any)
|
|
getRemoteJoinsMutationOutput
|
|
:: Backend b
|
|
=> MutationOutputG b u
|
|
-> (MutationOutputG b u, Maybe (RemoteJoins b))
|
|
getRemoteJoinsMutationOutput =
|
|
second mapToNonEmpty . flip runState mempty . transformMutationOutput mempty
|
|
where
|
|
transformMutationOutput path = \case
|
|
MOutMultirowFields mutationFields ->
|
|
MOutMultirowFields <$> transfromMutationFields mutationFields
|
|
MOutSinglerowObject annFields ->
|
|
MOutSinglerowObject <$> transformAnnFields path annFields
|
|
where
|
|
transfromMutationFields fields =
|
|
forM fields $ \(fieldName, field') -> do
|
|
let fieldPath = appendPath fieldName path
|
|
(fieldName,) <$> case field' of
|
|
MCount -> pure MCount
|
|
MExp t -> pure $ MExp t
|
|
MRet annFields -> MRet <$> transformAnnFields fieldPath annFields
|
|
|
|
|
|
-- local helpers
|
|
|
|
transformSelect
|
|
:: Backend b
|
|
=> FieldPath
|
|
-> AnnSimpleSelG b u
|
|
-> State (RemoteJoinMap b) (AnnSimpleSelG b u)
|
|
transformSelect path sel = do
|
|
let fields = _asnFields sel
|
|
-- Transform selects in array, object and computed fields
|
|
transformedFields <- transformAnnFields path fields
|
|
pure sel{_asnFields = transformedFields}
|
|
|
|
transformAggregateSelect
|
|
:: Backend b
|
|
=> FieldPath
|
|
-> AnnAggregateSelectG b u
|
|
-> State (RemoteJoinMap b) (AnnAggregateSelectG b u)
|
|
transformAggregateSelect path sel = do
|
|
let aggFields = _asnFields sel
|
|
transformedFields <- forM aggFields $ \(fieldName, aggField) ->
|
|
(fieldName,) <$> case aggField of
|
|
TAFAgg agg -> pure $ TAFAgg agg
|
|
TAFNodes x annFields -> TAFNodes x <$> transformAnnFields (appendPath fieldName path) annFields
|
|
TAFExp t -> pure $ TAFExp t
|
|
pure sel{_asnFields = transformedFields}
|
|
|
|
transformConnectionSelect
|
|
:: Backend b
|
|
=> FieldPath
|
|
-> ConnectionSelect b u
|
|
-> State (RemoteJoinMap b) (ConnectionSelect b u)
|
|
transformConnectionSelect path ConnectionSelect{..} = do
|
|
let connectionFields = _asnFields _csSelect
|
|
transformedFields <- forM connectionFields $ \(fieldName, field) ->
|
|
(fieldName,) <$> case field of
|
|
ConnectionTypename t -> pure $ ConnectionTypename t
|
|
ConnectionPageInfo p -> pure $ ConnectionPageInfo p
|
|
ConnectionEdges edges -> ConnectionEdges <$> transformEdges (appendPath fieldName path) edges
|
|
let select = _csSelect{_asnFields = transformedFields}
|
|
pure $ ConnectionSelect _csXRelay _csPrimaryKeyColumns _csSplit _csSlice select
|
|
where
|
|
transformEdges edgePath edgeFields =
|
|
forM edgeFields $ \(fieldName, edgeField) ->
|
|
(fieldName,) <$> case edgeField of
|
|
EdgeTypename t -> pure $ EdgeTypename t
|
|
EdgeCursor -> pure EdgeCursor
|
|
EdgeNode annFields ->
|
|
EdgeNode <$> transformAnnFields (appendPath fieldName edgePath) annFields
|
|
|
|
transformObjectSelect
|
|
:: Backend b
|
|
=> FieldPath
|
|
-> AnnObjectSelectG b u
|
|
-> State (RemoteJoinMap b) (AnnObjectSelectG b u)
|
|
transformObjectSelect path sel = do
|
|
let fields = _aosFields sel
|
|
transformedFields <- transformAnnFields path fields
|
|
pure sel{_aosFields = transformedFields}
|
|
|
|
transformAnnFields
|
|
:: forall b u
|
|
. Backend b
|
|
=> FieldPath
|
|
-> AnnFieldsG b u
|
|
-> State (RemoteJoinMap b) (AnnFieldsG b u)
|
|
transformAnnFields path fields = do
|
|
let pgColumnFields = map fst $ getFields _AFColumn fields
|
|
remoteSelects = getFields (_AFRemote . _2) fields
|
|
remoteJoins = flip map remoteSelects $ \(fieldName, remoteSelect) ->
|
|
let RemoteSelect argsMap selSet hasuraColumns remoteFields rsi = remoteSelect
|
|
hasuraColumnL = toList hasuraColumns
|
|
hasuraColumnFields = HS.fromList $ map (fromCol @b . pgiColumn) hasuraColumnL
|
|
phantomColumns = filter ((`notElem` pgColumnFields) . fromCol @b . pgiColumn) hasuraColumnL
|
|
in RemoteJoin fieldName argsMap selSet hasuraColumnFields remoteFields rsi phantomColumns
|
|
|
|
transformedFields <- forM fields $ \(fieldName, field') -> do
|
|
let fieldPath = appendPath fieldName path
|
|
(fieldName,) <$> case field' of
|
|
AFNodeId x qt pkeys -> pure $ AFNodeId x qt pkeys
|
|
AFColumn c -> pure $ AFColumn c
|
|
AFObjectRelation annRel ->
|
|
AFObjectRelation <$> transformAnnRelation annRel (transformObjectSelect fieldPath)
|
|
AFArrayRelation (ASSimple annRel) ->
|
|
AFArrayRelation . ASSimple <$> transformAnnRelation annRel (transformSelect fieldPath)
|
|
AFArrayRelation (ASAggregate aggRel) ->
|
|
AFArrayRelation . ASAggregate <$> transformAnnAggregateRelation fieldPath aggRel
|
|
AFArrayRelation (ASConnection annRel) ->
|
|
AFArrayRelation . ASConnection <$> transformArrayConnection fieldPath annRel
|
|
AFComputedField x computedField ->
|
|
AFComputedField x <$> case computedField of
|
|
CFSScalar _ _ -> pure computedField
|
|
CFSTable jas annSel -> CFSTable jas <$> transformSelect fieldPath annSel
|
|
AFRemote x rs -> pure $ AFRemote x rs
|
|
AFExpression t -> pure $ AFExpression t
|
|
|
|
case NE.nonEmpty remoteJoins of
|
|
Nothing -> pure transformedFields
|
|
Just nonEmptyRemoteJoins -> do
|
|
let phantomColumns = map (\ci -> (fromCol @b $ pgiColumn ci, AFColumn $ AnnColumnField ci False Nothing Nothing)) $
|
|
concatMap _rjPhantomFields remoteJoins
|
|
modify (Map.insert path nonEmptyRemoteJoins)
|
|
pure $ transformedFields <> phantomColumns
|
|
where
|
|
getFields f = mapMaybe (sequence . second (^? f))
|
|
|
|
transformAnnRelation annRel f = do
|
|
let annSel = aarAnnSelect annRel
|
|
transformedSel <- f annSel
|
|
pure annRel{aarAnnSelect = transformedSel}
|
|
|
|
transformAnnAggregateRelation fieldPath annRel = do
|
|
let annSel = aarAnnSelect annRel
|
|
transformedSel <- transformAggregateSelect fieldPath annSel
|
|
pure annRel{aarAnnSelect = transformedSel}
|
|
|
|
transformArrayConnection fieldPath annRel = do
|
|
let connectionSelect = aarAnnSelect annRel
|
|
transformedConnectionSelect <- transformConnectionSelect fieldPath connectionSelect
|
|
pure annRel{aarAnnSelect = transformedConnectionSelect}
|
|
|
|
mapToNonEmpty :: RemoteJoinMap backend -> Maybe (RemoteJoins backend)
|
|
mapToNonEmpty = NE.nonEmpty . Map.toList
|