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
167 lines
7.6 KiB
Haskell
167 lines
7.6 KiB
Haskell
module Hasura.GraphQL.Explain
|
|
( explainGQLQuery
|
|
, GQLExplain
|
|
) where
|
|
|
|
import Hasura.Prelude
|
|
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Casing as J
|
|
import qualified Data.Aeson.TH as J
|
|
import qualified Data.HashMap.Strict as Map
|
|
import qualified Data.HashMap.Strict.InsOrd as OMap
|
|
import qualified Database.PG.Query as Q
|
|
import qualified Language.GraphQL.Draft.Syntax as G
|
|
|
|
import Control.Monad.Trans.Control (MonadBaseControl)
|
|
import Data.Text.Extended
|
|
import Data.Typeable (cast)
|
|
|
|
import qualified Hasura.Backends.Postgres.SQL.DML as S
|
|
import qualified Hasura.Backends.Postgres.Translate.Select as DS
|
|
import qualified Hasura.GraphQL.Execute as E
|
|
import qualified Hasura.GraphQL.Execute.Backend as E
|
|
import qualified Hasura.GraphQL.Execute.Inline as E
|
|
import qualified Hasura.GraphQL.Execute.LiveQuery.Explain as E
|
|
import qualified Hasura.GraphQL.Execute.LiveQuery.Plan as EL
|
|
import qualified Hasura.GraphQL.Execute.Query as E
|
|
import qualified Hasura.GraphQL.Execute.RemoteJoin as RR
|
|
import qualified Hasura.GraphQL.Transport.HTTP.Protocol as GH
|
|
|
|
import Hasura.Backends.Postgres.SQL.Value
|
|
import Hasura.Backends.Postgres.Translate.Column (toTxtValue)
|
|
import Hasura.EncJSON
|
|
import Hasura.GraphQL.Context
|
|
import Hasura.GraphQL.Parser
|
|
import Hasura.RQL.DML.Internal
|
|
import Hasura.RQL.Types
|
|
import Hasura.SQL.Types
|
|
import Hasura.Session
|
|
|
|
|
|
data GQLExplain
|
|
= GQLExplain
|
|
{ _gqeQuery :: !GH.GQLReqParsed
|
|
, _gqeUser :: !(Maybe (Map.HashMap Text Text))
|
|
, _gqeIsRelay :: !(Maybe Bool)
|
|
} deriving (Show, Eq)
|
|
|
|
$(J.deriveJSON hasuraJSON{J.omitNothingFields=True}
|
|
''GQLExplain
|
|
)
|
|
|
|
data FieldPlan
|
|
= FieldPlan
|
|
{ _fpField :: !G.Name
|
|
, _fpSql :: !(Maybe Text)
|
|
, _fpPlan :: !(Maybe [Text])
|
|
} deriving (Show, Eq)
|
|
|
|
$(J.deriveJSON (J.aesonPrefix J.camelCase) ''FieldPlan)
|
|
|
|
resolveUnpreparedValue
|
|
:: (MonadError QErr m)
|
|
=> UserInfo -> UnpreparedValue 'Postgres -> m S.SQLExp
|
|
resolveUnpreparedValue userInfo = \case
|
|
UVParameter _ cv -> pure $ toTxtValue cv
|
|
UVLiteral sqlExp -> pure sqlExp
|
|
UVSession -> pure $ sessionInfoJsonExp $ _uiSession userInfo
|
|
UVSessionVar ty sessionVariable -> do
|
|
let maybeSessionVariableValue =
|
|
getSessionVariableValue sessionVariable (_uiSession userInfo)
|
|
|
|
sessionVariableValue <- fmap S.SELit $ onNothing maybeSessionVariableValue $
|
|
throw400 UnexpectedPayload $ "missing required session variable for role "
|
|
<> _uiRole userInfo <<> " : " <> sessionVariableToText sessionVariable
|
|
|
|
pure $ flip S.SETyAnn (S.mkTypeAnn ty) $ case ty of
|
|
CollectableTypeScalar colTy -> withConstructorFn colTy sessionVariableValue
|
|
CollectableTypeArray _ -> sessionVariableValue
|
|
|
|
-- NOTE: This function has a 'MonadTrace' constraint in master, but we don't need it
|
|
-- here. We should evaluate if we need it here.
|
|
explainQueryField
|
|
:: ( MonadError QErr m
|
|
, MonadIO m
|
|
, MonadBaseControl IO m
|
|
)
|
|
=> UserInfo
|
|
-> G.Name
|
|
-> QueryRootField UnpreparedValue
|
|
-> m (Maybe FieldPlan)
|
|
explainQueryField userInfo fieldName rootField = do
|
|
case rootField of
|
|
RFRemote _ -> throw400 InvalidParams "only hasura queries can be explained"
|
|
RFAction _ -> throw400 InvalidParams "query actions cannot be explained"
|
|
RFRaw _ -> pure $ Just $ FieldPlan fieldName Nothing Nothing
|
|
RFDB _ config (QDBR qDB) -> runMaybeT $ do
|
|
-- TEMPORARY!!!
|
|
-- We don't handle non-Postgres backends yet: for now, we filter root fields to only keep those
|
|
-- that are targeting postgres, and we *silently* discard all the others. This is fine for now, as
|
|
-- we haven't integrated any other backend yet, but will need to be fixed as soon as possible for
|
|
-- other backends to work.
|
|
pgConfig <- hoistMaybe $ cast config
|
|
pgQDB <- hoistMaybe $ cast qDB
|
|
lift $ do
|
|
resolvedQuery <- E.traverseQueryDB (resolveUnpreparedValue userInfo) pgQDB
|
|
let (querySQL, remoteJoins) = case resolvedQuery of
|
|
QDBMultipleRows s -> first (DS.selectQuerySQL JASMultipleRows) $ RR.getRemoteJoinsSelect s
|
|
QDBSingleRow s -> first (DS.selectQuerySQL JASSingleObject) $ RR.getRemoteJoinsSelect s
|
|
QDBAggregation s -> first DS.selectAggregateQuerySQL $ RR.getRemoteJoinsAggregateSelect s
|
|
QDBConnection s -> first DS.connectionSelectQuerySQL $ RR.getRemoteJoinsConnectionSelect s
|
|
textSQL = Q.getQueryText querySQL
|
|
-- CAREFUL!: an `EXPLAIN ANALYZE` here would actually *execute* this
|
|
-- query, maybe resulting in privilege escalation:
|
|
withExplain = "EXPLAIN (FORMAT TEXT) " <> textSQL
|
|
-- Reject if query contains any remote joins
|
|
when (remoteJoins /= mempty) $ throw400 NotSupported "Remote relationships are not allowed in explain query"
|
|
planLines <- liftEitherM $ runExceptT $ runLazyTx (_pscExecCtx pgConfig) Q.ReadOnly $
|
|
liftTx $ map runIdentity <$>
|
|
Q.listQE dmlTxErrorHandler (Q.fromText withExplain) () True
|
|
pure $ FieldPlan fieldName (Just textSQL) $ Just planLines
|
|
|
|
-- NOTE: This function has a 'MonadTrace' constraint in master, but we don't need it
|
|
-- here. We should evaluate if we need it here.
|
|
explainGQLQuery
|
|
:: forall m
|
|
. ( MonadError QErr m
|
|
, MonadIO m
|
|
, MonadBaseControl IO m
|
|
)
|
|
=> SchemaCache
|
|
-> GQLExplain
|
|
-> m EncJSON
|
|
explainGQLQuery sc (GQLExplain query userVarsRaw maybeIsRelay) = do
|
|
-- NOTE!: we will be executing what follows as though admin role. See e.g. notes in explainField:
|
|
userInfo <-
|
|
mkUserInfo (URBFromSessionVariablesFallback adminRoleName) UAdminSecretSent
|
|
sessionVariables
|
|
-- we don't need to check in allow list as we consider it an admin endpoint
|
|
let takeFragment =
|
|
\case G.ExecutableDefinitionFragment f -> Just f; _ -> Nothing
|
|
fragments = mapMaybe takeFragment $ GH.unGQLExecDoc $ GH._grQuery query
|
|
(graphQLContext, queryParts) <- E.getExecPlanPartial userInfo sc queryType query
|
|
case queryParts of
|
|
G.TypedOperationDefinition G.OperationTypeQuery _ varDefs _ selSet -> do
|
|
-- (Here the above fragment inlining is actually executed.)
|
|
inlinedSelSet <- E.inlineSelectionSet fragments selSet
|
|
(unpreparedQueries, _) <-
|
|
E.parseGraphQLQuery graphQLContext varDefs (GH._grVariables query) inlinedSelSet
|
|
encJFromJValue . catMaybes
|
|
<$> for (OMap.toList unpreparedQueries) (uncurry (explainQueryField userInfo))
|
|
|
|
G.TypedOperationDefinition G.OperationTypeMutation _ _ _ _ ->
|
|
throw400 InvalidParams "only queries can be explained"
|
|
|
|
G.TypedOperationDefinition G.OperationTypeSubscription _ varDefs _ selSet -> do
|
|
-- (Here the above fragment inlining is actually executed.)
|
|
inlinedSelSet <- E.inlineSelectionSet fragments selSet
|
|
(unpreparedQueries, _) <- E.parseGraphQLQuery graphQLContext varDefs (GH._grVariables query) inlinedSelSet
|
|
(_, E.LQP (execPlan :: EL.LiveQueryPlan b (E.MultiplexedQuery b))) <- E.createSubscriptionPlan userInfo unpreparedQueries
|
|
case backendTag @b of
|
|
PostgresTag -> encJFromJValue <$> E.explainLiveQueryPlan execPlan
|
|
MSSQLTag -> pure mempty
|
|
where
|
|
queryType = bool E.QueryHasura E.QueryRelay $ Just True == maybeIsRelay
|
|
sessionVariables = mkSessionVariablesText $ fromMaybe mempty userVarsRaw
|