backend only insert permissions (rfc #4120) (#4224)

* move user info related code to Hasura.User module

* the RFC #4120 implementation; insert permissions with admin secret

* revert back to old RoleName based schema maps

An attempt made to avoid duplication of schema contexts in types
if any role doesn't possess any admin secret specific schema

* fix compile errors in haskell test

* keep 'user_vars' for session variables in http-logs

* no-op refacto

* tests for admin only inserts

* update docs for admin only inserts

* updated CHANGELOG.md

* default behaviour when admin secret is not set

* fix x-hasura-role to X-Hasura-Role in pytests

* introduce effective timeout in actions async tests

* update docs for admin-secret not configured case

* Update docs/graphql/manual/api-reference/schema-metadata-api/permission.rst

Co-Authored-By: Marion Schleifer <marion@hasura.io>

* Apply suggestions from code review

Co-Authored-By: Marion Schleifer <marion@hasura.io>

* a complete iteration

backend insert permissions accessable via 'x-hasura-backend-privilege'
session variable

* console changes for backend-only permissions

* provide tooltip id; update labels and tooltips;

* requested changes

* requested changes

- remove className from Toggle component
- use appropriate function name (capitalizeFirstChar -> capitalize)

* use toggle props from definitelyTyped

* fix accidental commit

* Revert "introduce effective timeout in actions async tests"

This reverts commit b7a59c19d6.

* generate complete schema for both 'default' and 'backend' sessions

* Apply suggestions from code review

Co-Authored-By: Marion Schleifer <marion@hasura.io>

* remove unnecessary import, export Toggle as is

* update session variable in tooltip

* 'x-hasura-use-backend-only-permissions' variable to switch

* update help texts

* update docs

* update docs

* update console help text

* regenerate package-lock

* serve no backend schema when backend_only: false and header set to true

- Few type name refactor as suggested by @0x777

* update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* fix a merge bug where a certain entity didn't get removed

Co-authored-by: Marion Schleifer <marion@hasura.io>
Co-authored-by: Rishichandra Wawhal <rishi@hasura.io>
Co-authored-by: rikinsk <rikin.kachhia@gmail.com>
Co-authored-by: Tirumarai Selvan <tiru@hasura.io>
This commit is contained in:
Rakesh Emmadi 2020-04-24 14:40:53 +05:30 committed by GitHub
parent 6f100e0009
commit d52bfcda4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 988 additions and 475 deletions

View File

@ -2,6 +2,18 @@
## Next release
### server: backend only insert permissions
Introduces optional `backend_only` (default: `false`) configuration in insert permissions
(see [api reference](https://deploy-preview-4224--hasura-docs.netlify.com/graphql/manual/api-reference/schema-metadata-api/permission.html#insertpermission)).
If this is set to `true`, the insert mutation is accessible to the role only if the request
is accompanied by `x-hasura-use-backend-only-permissions` session variable whose value is set to `true` along with the `x-hasura-admin-secret` header.
Otherwise, the behavior of the permission remains unchanged.
This feature is highly useful in disabling `insert_table` mutation for a role from frontend clients while still being able to access it from a Action webhook handler (with the same role).
(rfc #4120) (#4224)
### server: debugging mode for non-admin roles
For any errors the server sends extra information in `extensions` field under `internal` key. Till now this was only
@ -224,7 +236,6 @@ A new CLI migrations image is introduced to account for the new migrations workf
(close #3969) (#4145)
### Bug fixes and improvements
- server: improve performance of replace_metadata tracking many tables (fix #3802)
- server: option to reload remote schemas in 'reload_metadata' API (fix #3792, #4117)
- server: fix various space leaks to avoid excessive memory consumption

View File

@ -2133,6 +2133,15 @@
"@types/react": "*"
}
},
"@types/react-toggle": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/react-toggle/-/react-toggle-4.0.2.tgz",
"integrity": "sha512-sHqfoKFnL0YU2+OC4meNEC8Ptx9FE8/+nFeFvNcdBa6ANA8KpAzj3R9JN8GtrvlLgjKDoYgI7iILgXYcTPo2IA==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-transition-group": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.9.2.tgz",

View File

@ -163,6 +163,7 @@
"@types/react-redux": "^7.1.7",
"@types/react-router": "^5.1.4",
"@types/react-router-redux": "^5.0.18",
"@types/react-toggle": "^4.0.2",
"@types/redux-devtools": "^3.0.47",
"@types/redux-devtools-dock-monitor": "^1.1.33",
"@types/redux-devtools-log-monitor": "^1.0.34",

View File

@ -0,0 +1,5 @@
import Toggle from 'react-toggle';
import './Toggle.css';
import 'react-toggle/style.css';
export default Toggle;

View File

@ -43,12 +43,14 @@ const defaultQueryPermissions = {
insert: {
check: {},
allow_upsert: true,
backend_only: false,
set: {},
columns: [],
},
select: {
columns: [],
computed_fields: [],
backend_only: false,
filter: {},
limit: null,
allow_aggregations: false,
@ -56,9 +58,11 @@ const defaultQueryPermissions = {
update: {
columns: [],
filter: {},
backend_only: false,
set: {},
},
delete: {
backend_only: false,
filter: {},
},
};

View File

@ -1,11 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import Helmet from 'react-helmet';
import Toggle from 'react-toggle';
import 'react-toggle/style.css';
import '../../../Common/ReactToggle/ReactToggleOverrides.css';
import Toggle from '../../../Common/Toggle/Toggle';
import { updateMigrationModeStatus } from '../../../Main/Actions';
import { getConfirmation } from '../../../Common/utils/jsUtils';

View File

@ -63,6 +63,7 @@ import {
PERM_RESET_APPLY_SAME,
PERM_SET_APPLY_SAME_PERM,
PERM_DEL_APPLY_SAME_PERM,
PERM_TOGGLE_BACKEND_ONLY,
toggleField,
toggleAllFields,
getBasePermissionsState,
@ -484,7 +485,19 @@ const modifyReducer = (tableName, schemas, modifyStateOrig, action) => {
),
},
};
case PERM_TOGGLE_BACKEND_ONLY:
const pState = modifyState.permissionsState;
const isBackendOnly =
pState[pState.query] && pState[pState.query].backend_only;
return {
...modifyState,
permissionsState: updatePermissionsState(
modifyState.permissionsState,
'backend_only',
!isBackendOnly
),
};
/* Preset operations */
case DELETE_PRESET:
const deletedSet = {
...modifyState.permissionsState[action.data.queryType].set,

View File

@ -12,6 +12,7 @@ import {
getCreatePermissionQuery,
getDropPermissionQuery,
} from '../../../Common/utils/v1QueryUtils';
import { capitalize } from '../../../Common/utils/jsUtils';
export const PERM_OPEN_EDIT = 'ModifyTable/PERM_OPEN_EDIT';
export const PERM_SET_FILTER_TYPE = 'ModifyTable/PERM_SET_FILTER_TYPE';
@ -37,6 +38,7 @@ export const PERM_RESET_BULK_SELECT = 'ModifyTable/PERM_RESET_BULK_SELECT';
export const PERM_RESET_APPLY_SAME = 'ModifyTable/PERM_RESET_APPLY_SAME';
export const PERM_SET_APPLY_SAME_PERM = 'ModifyTable/PERM_SET_APPLY_SAME_PERM';
export const PERM_DEL_APPLY_SAME_PERM = 'ModifyTable/PERM_DEL_APPLY_SAME_PERM';
export const PERM_TOGGLE_BACKEND_ONLY = 'ModifyTable/PERM_TOGGLE_BACKEND_ONLY';
export const X_HASURA_CONST = 'x-hasura-';
@ -93,6 +95,9 @@ const permToggleAllowAggregation = checked => ({
type: PERM_TOGGLE_ALLOW_AGGREGATION,
data: checked,
});
export const permToggleBackendOnly = () => ({
type: PERM_TOGGLE_BACKEND_ONLY,
});
const permToggleModifyLimit = limit => ({
type: PERM_TOGGLE_MODIFY_LIMIT,
data: limit,
@ -517,6 +522,10 @@ const applySamePermissionsBulk = (tableSchema, arePermissionsModified) => {
};
};
export const isQueryTypeBackendOnlyCompatible = queryType => {
return queryType === 'insert';
};
const copyRolePermissions = (
fromRole,
tableNameWithSchema,
@ -718,9 +727,13 @@ const permChangePermissions = changeType => {
'_table_' +
table;
const requestMsg = getIngForm(changeType) + ' Permissions...';
const requestMsg = capitalize(
getIngForm(changeType) + ' permissions...'
);
const successMsg = 'Permissions ' + getEdForm(changeType);
const errorMsg = getIngForm(changeType) + ' permissions failed';
const errorMsg = capitalize(
getIngForm(changeType) + ' permissions failed'
);
const customOnSuccess = () => {
if (changeType === permChangeTypes.save) {

View File

@ -28,7 +28,9 @@ import {
permRemoveMultipleRoles,
permSetApplySamePerm,
permDelApplySamePerm,
permToggleBackendOnly,
applySamePermissionsBulk,
isQueryTypeBackendOnlyCompatible,
SET_PRESET_VALUE,
DELETE_PRESET,
X_HASURA_CONST,
@ -42,6 +44,7 @@ import styles from '../../../Common/Permissions/PermissionStyles.scss';
import PermissionBuilder from './PermissionBuilder/PermissionBuilder';
import TableHeader from '../TableCommon/TableHeader';
import CollapsibleToggle from '../../../Common/CollapsibleToggle/CollapsibleToggle';
import Toggle from '../../../Common/Toggle/Toggle';
import EnhancedInput from '../../../Common/InputChecker/InputChecker';
import {
fetchFunctionInit,
@ -79,6 +82,7 @@ import {
QUERY_TYPES,
} from '../../../Common/utils/pgUtils';
import { showErrorNotification } from '../../Common/Notification';
import KnowMoreLink from "../../../Common/KnowMoreLink/KnowMoreLink";
import {
getFilterQueries,
replaceLegacyOperators,
@ -582,7 +586,7 @@ class Permissions extends Component {
sectionClasses += ' ' + styles.disabled;
}
const getSectionHeader = (title, toolTip, sectionStatus) => {
const getSectionHeader = (title, toolTip, sectionStatus, knowMoreRef) => {
let sectionStatusHtml;
if (sectionStatus) {
sectionStatusHtml = (
@ -592,9 +596,18 @@ class Permissions extends Component {
);
}
let knowMoreHtml;
if(knowMoreRef) {
knowMoreHtml = (
<span className={`${styles.add_mar_left_small} ${styles.sectionStatus}`}>
<KnowMoreLink href={knowMoreRef}/>
</span>
)
}
return (
<div>
{addTooltip(title, toolTip)} {sectionStatusHtml}
{addTooltip(title, toolTip)} {knowMoreHtml} {sectionStatusHtml}
</div>
);
};
@ -1812,6 +1825,43 @@ class Permissions extends Component {
);
};
const getBackendOnlySection = () => {
if (!isQueryTypeBackendOnlyCompatible(permissionsState.query)) {
return null;
}
const tooltip = (
<Tooltip id="tooltip-backend-only">
When enabled, this {permissionsState.query} mutation is accessible
only via "trusted backends"
</Tooltip>
);
const isBackendOnly = !!(
permissionsState[permissionsState.query] &&
permissionsState[permissionsState.query].backend_only
);
const backendStatus = isBackendOnly ? 'enabled' : 'disabled';
return (
<CollapsibleToggle
title={getSectionHeader('Backend only', tooltip, backendStatus, 'https://docs.hasura.io/1.0/graphql/manual/auth/authorization/permission-rules.html#backend-only-inserts')}
useDefaultTitleStyle
testId={'toggle-backend-only'}
>
<div
className={`${styles.editPermsSection} ${styles.display_flex}`}
>
<div className={`${styles.display_flex} ${styles.add_mar_right_mid}`}>
<Toggle
checked={isBackendOnly}
onChange={() => dispatch(permToggleBackendOnly())}
icons={false}
/>
</div>
<span>Allow from backends only</span>
</div>
</CollapsibleToggle>
);
};
return (
<div
id={'permission-edit-section'}
@ -1840,6 +1890,7 @@ class Permissions extends Component {
{/*{getUpsertSection()}*/}
{getPresetsSection('insert')}
{getPresetsSection('update')}
{getBackendOnlySection()}
{getButtonsSection()}
{getClonePermsSection()}
</div>

View File

@ -154,11 +154,12 @@ InsertPermission
- false
- :ref:`PGColumn` array (or) ``'*'``
- Can insert into only these columns (or all when ``'*'`` is specified)
* - backend_only
- false
- Boolean
- When set to ``true`` the mutation is accessible only if ``x-hasura-use-backend-only-permissions``
session variable exists and is set to ``true`` and request is made with ``x-hasura-admin-secret``
set if any auth is configured
.. _drop_insert_permission:

View File

@ -62,6 +62,8 @@ For ``insert`` operations or for GraphQL mutations of the type *insert*, you can
* :ref:`col-presets-permissions`
* :ref:`backend-only-permissions`
**Update** permissions
^^^^^^^^^^^^^^^^^^^^^^
For ``update`` operations or for GraphQL mutations of the type *update*, you can configure the following:
@ -245,3 +247,15 @@ While this is strictly not a permission configuration, defining
removes access to it. This preset can be defined for ``insert`` and ``update`` operations. This configuration
is also very useful to avoid sending sensitive user-information in the query and leverage session variables
or static data instead.
.. _backend-only-permissions:
Backend only inserts
^^^^^^^^^^^^^^^^^^^^
If an insert mutation permission is marked as ``backend_only``, the mutation is accessible to the
given role only if ``x-hasura-use-backend-only-permissions`` session variable exists and is set to ``true``
and request is made with ``x-hasura-admin-secret`` set if any auth is configured.
This might be useful if you would like to hide a mutation from the public facing API but allow access to it
via a "trusted backend".

View File

@ -240,6 +240,7 @@ library
, Hasura.RQL.DDL.Metadata.Generator
, Hasura.RQL.DDL.Schema
, Hasura.EncJSON
, Hasura.Session
, Data.Aeson.Ordered

View File

@ -40,9 +40,8 @@ import Hasura.Prelude
import Hasura.RQL.Types (CacheRWM, Code (..), HasHttpManager,
HasSQLGenCtx, HasSystemDefined, QErr (..),
SQLGenCtx (..), SchemaCache (..), UserInfoM,
adminRole, adminUserInfo,
buildSchemaCacheStrict, decodeValue,
throw400, userRole, withPathK)
throw400, withPathK)
import Hasura.RQL.Types.Run
import Hasura.Server.API.Query (requiresAdmin, runQueryM)
import Hasura.Server.App
@ -53,6 +52,7 @@ import Hasura.Server.Logging
import Hasura.Server.SchemaUpdate
import Hasura.Server.Telemetry
import Hasura.Server.Version
import Hasura.Session
printErrExit :: (MonadIO m) => forall a . String -> m a
@ -395,8 +395,8 @@ instance UserAuthentication AppM where
instance MetadataApiAuthorization AppM where
authorizeMetadataApi query userInfo = do
let currRole = userRole userInfo
when (requiresAdmin query && currRole /= adminRole) $
let currRole = _uiRole userInfo
when (requiresAdmin query && currRole /= adminRoleName) $
withPathK "args" $ throw400 AccessDenied errMsg
where
errMsg = "restricted access : admin only"

View File

@ -30,7 +30,7 @@ import qualified Database.PG.Query.Connection as Q
import Hasura.EncJSON
import Hasura.Prelude
import Hasura.RQL.Types.Error
import Hasura.RQL.Types.Permission
import Hasura.Session
import Hasura.SQL.Error
import Hasura.SQL.Types
@ -93,14 +93,14 @@ runLazyTx' (PGExecCtx pgPool _) = \case
type RespTx = Q.TxE QErr EncJSON
type LazyRespTx = LazyTx QErr EncJSON
setHeadersTx :: UserVars -> Q.TxE QErr ()
setHeadersTx uVars =
setHeadersTx :: SessionVariables -> Q.TxE QErr ()
setHeadersTx session =
Q.unitQE defaultTxErrorHandler setSess () False
where
setSess = Q.fromText $
"SET LOCAL \"hasura.user\" = " <> toSQLTxt (sessionInfoJsonExp uVars)
"SET LOCAL \"hasura.user\" = " <> toSQLTxt (sessionInfoJsonExp session)
sessionInfoJsonExp :: UserVars -> S.SQLExp
sessionInfoJsonExp :: SessionVariables -> S.SQLExp
sessionInfoJsonExp = S.SELit . J.encodeToStrictText
defaultTxErrorHandler :: Q.PGTxErr -> QErr
@ -142,7 +142,7 @@ withUserInfo :: UserInfo -> LazyTx QErr a -> LazyTx QErr a
withUserInfo uInfo = \case
LTErr e -> LTErr e
LTNoTx a -> LTNoTx a
LTTx tx -> LTTx $ setHeadersTx (userVars uInfo) >> tx
LTTx tx -> LTTx $ setHeadersTx (_uiSession uInfo) >> tx
instance Functor (LazyTx e) where
fmap f = \case

View File

@ -3,6 +3,8 @@ module Hasura.GraphQL.Context where
import Hasura.Prelude
import Data.Aeson
import Data.Aeson.Casing
import Data.Aeson.TH
import Data.Has
import qualified Data.HashMap.Strict as Map
@ -11,8 +13,7 @@ import qualified Language.GraphQL.Draft.Syntax as G
import Hasura.GraphQL.Resolve.Types
import Hasura.GraphQL.Validate.Types
import Hasura.RQL.Instances ()
import Hasura.RQL.Types.Permission
import Hasura.Session
-- | A /GraphQL context/, aka the final output of GraphQL schema generation. Used to both validate
-- incoming queries and respond to introspection queries.
@ -49,12 +50,27 @@ instance Has TypeMap GCtx where
instance ToJSON GCtx where
toJSON _ = String "ToJSON for GCtx is not implemented"
type GCtxMap = Map.HashMap RoleName GCtx
data RoleContext a
= RoleContext
{ _rctxDefault :: !a -- ^ The default context for normal sessions
, _rctxBackend :: !(Maybe a) -- ^ The context for sessions with backend privilege.
} deriving (Show, Eq, Functor, Foldable, Traversable)
$(deriveToJSON (aesonDrop 5 snakeCase) ''RoleContext)
type GCtxMap = Map.HashMap RoleName (RoleContext GCtx)
queryRootNamedType :: G.NamedType
queryRootNamedType = G.NamedType "query_root"
mutationRootNamedType :: G.NamedType
mutationRootNamedType = G.NamedType "mutation_root"
subscriptionRootNamedType :: G.NamedType
subscriptionRootNamedType = G.NamedType "subscription_root"
mkQueryRootTyInfo :: [ObjFldInfo] -> ObjTyInfo
mkQueryRootTyInfo flds =
mkHsraObjTyInfo (Just "query root")
(G.NamedType "query_root") Set.empty $
mkHsraObjTyInfo (Just "query root") queryRootNamedType Set.empty $
mapFromL _fiName $ schemaFld:typeFld:flds
where
schemaFld = mkHsraObjFldInfo Nothing "__schema" Map.empty $

View File

@ -23,10 +23,8 @@ module Hasura.GraphQL.Execute
import Control.Exception (try)
import Control.Lens
import Data.Has
import Data.Text.Conversions
import qualified Data.Aeson as J
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as Map
import qualified Data.HashSet as Set
import qualified Data.Text as T
@ -50,6 +48,7 @@ import Hasura.RQL.Types
import Hasura.Server.Utils (RequestId, mkClientHeadersForward,
mkSetCookieHeaders)
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import qualified Hasura.GraphQL.Execute.LiveQuery as EL
import qualified Hasura.GraphQL.Execute.Plan as EP
@ -131,7 +130,7 @@ getExecPlanPartial userInfo sc enableAL req = do
-- check if query is in allowlist
when enableAL checkQueryInAllowlist
gCtx <- flip runCacheRT sc $ getGCtx role gCtxRoleMap
let gCtx = getGCtx (_uiBackendOnlyFieldAccess userInfo) sc roleName
queryParts <- flip runReaderT gCtx $ VQ.getQueryParts req
let opDef = VQ.qpOpDef queryParts
@ -151,12 +150,11 @@ getExecPlanPartial userInfo sc enableAL req = do
VT.TLCustom ->
throw500 "unexpected custom type for top level field"
where
role = userRole userInfo
gCtxRoleMap = scGCtxMap sc
roleName = _uiRole userInfo
checkQueryInAllowlist =
-- only for non-admin roles
when (role /= adminRole) $ do
when (roleName /= adminRoleName) $ do
let notInAllowlist =
not $ VQ.isQueryInAllowlist (_grQuery req) (scAllowlist sc)
when notInAllowlist $ modifyQErr modErr $ throwVE "query is not allowed"
@ -192,9 +190,9 @@ getResolvedExecPlan
-> m (Telem.CacheHit, ExecPlanResolved)
getResolvedExecPlan pgExecCtx planCache userInfo sqlGenCtx
enableAL sc scVer httpManager reqHeaders reqUnparsed = do
planM <- liftIO $ EP.getPlan scVer (userRole userInfo)
planM <- liftIO $ EP.getPlan scVer (_uiRole userInfo)
opNameM queryStr planCache
let usrVars = userVars userInfo
let usrVars = _uiSession userInfo
case planM of
-- plans are only for queries and subscriptions
Just plan -> (Telem.Hit,) . GExPHasura <$> case plan of
@ -207,7 +205,7 @@ getResolvedExecPlan pgExecCtx planCache userInfo sqlGenCtx
where
GQLReq opNameM queryStr queryVars = reqUnparsed
addPlanToCache plan =
liftIO $ EP.addPlan scVer (userRole userInfo)
liftIO $ EP.addPlan scVer (_uiRole userInfo)
opNameM queryStr plan planCache
noExistingPlan = do
req <- toParsed reqUnparsed
@ -272,9 +270,6 @@ getQueryOp
getQueryOp gCtx sqlGenCtx userInfo queryReusability actionExecuter selSet =
runE gCtx sqlGenCtx userInfo $ EQ.convertQuerySelSet queryReusability selSet actionExecuter
mutationRootName :: Text
mutationRootName = "mutation_root"
resolveMutSelSet
:: ( HasVersion
, MonadError QErr m
@ -294,7 +289,7 @@ resolveMutSelSet
resolveMutSelSet fields = do
aliasedTxs <- forM (toList fields) $ \fld -> do
fldRespTx <- case VQ._fName fld of
"__typename" -> return (return $ encJFromJValue mutationRootName, [])
"__typename" -> return (return $ encJFromJValue mutationRootNamedType, [])
_ -> evalReusabilityT $ GR.mutFldToTx fld
return (G.unName $ G.unAlias $ VQ._fAlias fld, fldRespTx)
@ -431,5 +426,4 @@ execRemoteGQ reqId userInfo reqHdrs q rsi opDef = do
HTTP.HttpExceptionRequest _req content -> throw500 $ T.pack . show $ content
HTTP.InvalidUrlException _url reason -> throw500 $ T.pack . show $ reason
userInfoToHdrs = userInfoToList userInfo
& map (CI.mk . unUTF8 . fromText *** unUTF8 . fromText)
userInfoToHdrs = sessionVariablesToHeaders $ _uiSession userInfo

View File

@ -24,6 +24,7 @@ module Hasura.GraphQL.Execute.LiveQuery.Plan
) where
import Hasura.Prelude
import Hasura.Session
import qualified Data.Aeson.Casing as J
import qualified Data.Aeson.Extended as J
@ -98,7 +99,7 @@ resolveMultiplexedValue = \case
modifying _2 (|> colVal)
pure ["synthetic", T.pack $ show syntheticVarIndex]
pure $ fromResVars (PGTypeScalar $ pstType colVal) varJsonPath
GR.UVSessVar ty sessVar -> pure $ fromResVars ty ["session", T.toLower sessVar]
GR.UVSessVar ty sessVar -> pure $ fromResVars ty ["session", sessionVariableToText sessVar]
GR.UVSQL sqlExp -> pure sqlExp
GR.UVSession -> pure $ fromResVars (PGTypeScalar PGJSON) ["session"]
where
@ -116,7 +117,7 @@ newCohortId = CohortId <$> liftIO UUID.nextRandom
data CohortVariables
= CohortVariables
{ _cvSessionVariables :: !UserVars
{ _cvSessionVariables :: !SessionVariables
, _cvQueryVariables :: !ValidatedQueryVariables
, _cvSyntheticVariables :: !ValidatedSyntheticVariables
-- ^ To allow more queries to be multiplexed together, we introduce “synthetic” variables for
@ -261,7 +262,8 @@ buildLiveQueryPlan pgExecCtx fieldAlias astUnresolved varTypes = do
(astResolved, (queryVariableValues, syntheticVariableValues)) <- flip runStateT mempty $
GR.traverseQueryRootFldAST resolveMultiplexedValue astUnresolved
let pgQuery = mkMultiplexedQuery $ GR.toPGQuery astResolved
parameterizedPlan = ParameterizedLiveQueryPlan (userRole userInfo) fieldAlias pgQuery
roleName = _uiRole userInfo
parameterizedPlan = ParameterizedLiveQueryPlan roleName fieldAlias pgQuery
-- We need to ensure that the values provided for variables
-- are correct according to Postgres. Without this check
@ -269,7 +271,7 @@ buildLiveQueryPlan pgExecCtx fieldAlias astUnresolved varTypes = do
-- subscription will take down the entire multiplexed query
validatedQueryVars <- validateVariables pgExecCtx queryVariableValues
validatedSyntheticVars <- validateVariables pgExecCtx (toList syntheticVariableValues)
let cohortVariables = CohortVariables (userVars userInfo) validatedQueryVars validatedSyntheticVars
let cohortVariables = CohortVariables (_uiSession userInfo) validatedQueryVars validatedSyntheticVars
plan = LiveQueryPlan parameterizedPlan cohortVariables
reusablePlan = ReusableLiveQueryPlan parameterizedPlan validatedSyntheticVars <$> varTypes
pure (plan, reusablePlan)
@ -277,7 +279,7 @@ buildLiveQueryPlan pgExecCtx fieldAlias astUnresolved varTypes = do
reuseLiveQueryPlan
:: (MonadError QErr m, MonadIO m)
=> PGExecCtx
-> UserVars
-> SessionVariables
-> Maybe GH.VariableValues
-> ReusableLiveQueryPlan
-> m LiveQueryPlan

View File

@ -59,7 +59,8 @@ import Hasura.EncJSON
import Hasura.GraphQL.Execute.LiveQuery.Options
import Hasura.GraphQL.Execute.LiveQuery.Plan
import Hasura.GraphQL.Transport.HTTP.Protocol
import Hasura.RQL.Types
import Hasura.RQL.Types.Error
import Hasura.Session
-- -------------------------------------------------------------------------------------------------
-- Subscribers

View File

@ -11,6 +11,7 @@ module Hasura.GraphQL.Execute.Plan
) where
import Hasura.Prelude
import Hasura.Session
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J

View File

@ -12,7 +12,6 @@ import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as Map
import qualified Data.IntMap as IntMap
import qualified Data.TByteString as TBS
import qualified Data.Text as T
import qualified Database.PG.Query as Q
import qualified Language.GraphQL.Draft.Syntax as G
@ -24,17 +23,19 @@ import qualified Hasura.GraphQL.Transport.HTTP.Protocol as GH
import qualified Hasura.GraphQL.Validate as GV
import qualified Hasura.GraphQL.Validate.Field as V
import qualified Hasura.SQL.DML as S
import Hasura.Server.Version (HasVersion)
import Hasura.EncJSON
import Hasura.GraphQL.Context
import Hasura.GraphQL.Resolve.Action
import Hasura.GraphQL.Resolve.Types
import Hasura.GraphQL.Validate.Types
import Hasura.Prelude
import Hasura.RQL.DML.Select (asSingleRowJsonResp)
import Hasura.RQL.Types
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import Hasura.SQL.Types
import Hasura.SQL.Value
import Hasura.GraphQL.Resolve.Action
type PlanVariables = Map.HashMap G.Variable Int
@ -84,10 +85,10 @@ instance J.ToJSON ReusableQueryPlan where
withPlan
:: (MonadError QErr m)
=> UserVars -> PGPlan -> ReusableVariableValues -> m PreparedSql
=> SessionVariables -> PGPlan -> ReusableVariableValues -> m PreparedSql
withPlan usrVars (PGPlan q reqVars prepMap) annVars = do
prepMap' <- foldM getVar prepMap (Map.toList reqVars)
let args = withUserVars usrVars $ IntMap.elems prepMap'
let args = withSessionVariables usrVars $ IntMap.elems prepMap'
return $ PreparedSql q args
where
getVar accum (var, prepNo) = do
@ -100,7 +101,7 @@ withPlan usrVars (PGPlan q reqVars prepMap) annVars = do
-- turn the current plan into a transaction
mkCurPlanTx
:: (MonadError QErr m)
=> UserVars
=> SessionVariables
-> FieldPlans
-> m (LazyRespTx, GeneratedSqlMap)
mkCurPlanTx usrVars fldPlans = do
@ -109,14 +110,14 @@ mkCurPlanTx usrVars fldPlans = do
fldResp <- case fldPlan of
RFPRaw resp -> return $ RRRaw resp
RFPPostgres (PGPlan q _ prepMap) -> do
let args = withUserVars usrVars $ IntMap.elems prepMap
let args = withSessionVariables usrVars $ IntMap.elems prepMap
return $ RRSql $ PreparedSql q args
return (alias, fldResp)
return (mkLazyRespTx resolved, mkGeneratedSqlMap resolved)
withUserVars :: UserVars -> [(Q.PrepArg, PGScalarValue)] -> [(Q.PrepArg, PGScalarValue)]
withUserVars usrVars list =
withSessionVariables :: SessionVariables -> [(Q.PrepArg, PGScalarValue)] -> [(Q.PrepArg, PGScalarValue)]
withSessionVariables usrVars list =
let usrVarsAsPgScalar = PGValJSON $ Q.JSON $ J.toJSON usrVars
prepArg = Q.toPrepVal (Q.AltJ usrVars)
in (prepArg, usrVarsAsPgScalar):list
@ -167,7 +168,7 @@ prepareWithPlan = \case
R.UVSessVar ty sessVar -> do
let sessVarVal =
S.SEOpApp (S.SQLOp "->>")
[currentSession, S.SELit $ T.toLower sessVar]
[currentSession, S.SELit $ sessionVariableToText sessVar]
return $ flip S.SETyAnn (S.mkTypeAnn ty) $ case ty of
PGTypeScalar colTy -> withConstructorFn colTy sessVarVal
PGTypeArray _ -> sessVarVal
@ -177,9 +178,6 @@ prepareWithPlan = \case
where
currentSession = S.SEPrep 1
queryRootName :: Text
queryRootName = "query_root"
convertQuerySelSet
:: ( MonadError QErr m
, MonadReader r m
@ -197,13 +195,13 @@ convertQuerySelSet
-> QueryActionExecuter
-> m (LazyRespTx, Maybe ReusableQueryPlan, GeneratedSqlMap)
convertQuerySelSet initialReusability fields actionRunner = do
usrVars <- asks (userVars . getter)
usrVars <- asks (_uiSession . getter)
(fldPlans, finalReusability) <- runReusabilityTWith initialReusability $
forM (toList fields) $ \fld -> do
fldPlan <- case V._fName fld of
"__type" -> fldPlanFromJ <$> R.typeR fld
"__schema" -> fldPlanFromJ <$> R.schemaR fld
"__typename" -> pure $ fldPlanFromJ queryRootName
"__typename" -> pure $ fldPlanFromJ queryRootNamedType
_ -> do
unresolvedAst <- R.queryFldToPGAST fld actionRunner
(q, PlanningSt _ vars prepped) <- flip runStateT initPlanningSt $
@ -218,7 +216,7 @@ convertQuerySelSet initialReusability fields actionRunner = do
-- use the existing plan and new variables to create a pg query
queryOpFromPlan
:: (MonadError QErr m)
=> UserVars
=> SessionVariables
-> Maybe GH.VariableValues
-> ReusableQueryPlan
-> m (LazyRespTx, GeneratedSqlMap)

View File

@ -12,14 +12,15 @@ import qualified Language.GraphQL.Draft.Syntax as G
import Hasura.EncJSON
import Hasura.GraphQL.Context
import Hasura.GraphQL.Resolve.Action
import Hasura.GraphQL.Validate.Types (evalReusabilityT, runReusabilityT)
import Hasura.Prelude
import Hasura.RQL.DML.Internal
import Hasura.RQL.Types
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import Hasura.SQL.Types
import Hasura.SQL.Value
import Hasura.Server.Version (HasVersion)
import Hasura.GraphQL.Resolve.Action
import qualified Hasura.GraphQL.Execute as E
import qualified Hasura.GraphQL.Execute.LiveQuery as E
@ -68,19 +69,19 @@ resolveVal userInfo = \case
PGTypeScalar colTy -> withConstructorFn colTy sessVarVal
PGTypeArray _ -> sessVarVal
RS.UVSQL sqlExp -> return sqlExp
RS.UVSession -> pure $ sessionInfoJsonExp $ userVars userInfo
RS.UVSession -> pure $ sessionInfoJsonExp $ _uiSession userInfo
getSessVarVal
:: (MonadError QErr m)
=> UserInfo -> SessVar -> m SessVarVal
=> UserInfo -> SessionVariable -> m Text
getSessVarVal userInfo sessVar =
onNothing (getVarVal sessVar usrVars) $
onNothing (getSessionVariableValue sessVar sessionVariables) $
throw400 UnexpectedPayload $
"missing required session variable for role " <> rn <<>
" : " <> sessVar
" : " <> sessionVariableToText sessVar
where
rn = userRole userInfo
usrVars = userVars userInfo
rn = _uiRole userInfo
sessionVariables = _uiSession userInfo
explainField
:: (MonadError QErr m, MonadTx m, HasVersion, MonadIO m)
@ -122,6 +123,7 @@ explainGQLQuery
-> GQLExplain
-> m EncJSON
explainGQLQuery pgExecCtx sc sqlGenCtx enableAL actionExecuter (GQLExplain query userVarsRaw) = do
userInfo <- mkUserInfo UAdminSecretSent sessionVariables $ Just adminRoleName
(execPlan, queryReusability) <- runReusabilityT $
E.getExecPlanPartial userInfo sc enableAL query
(gCtx, rootSelSet) <- case execPlan of
@ -139,6 +141,5 @@ explainGQLQuery pgExecCtx sc sqlGenCtx enableAL actionExecuter (GQLExplain query
(plan, _) <- E.getSubsOp pgExecCtx gCtx sqlGenCtx userInfo queryReusability actionExecuter rootField
runInTx $ encJFromJValue <$> E.explainLiveQueryPlan plan
where
usrVars = mkUserVars $ maybe [] Map.toList userVarsRaw
userInfo = mkUserInfo (fromMaybe adminRole $ roleFromVars usrVars) usrVars
sessionVariables = mkSessionVariablesText $ maybe [] Map.toList userVarsRaw
runInTx = liftEither <=< liftIO . runExceptT . runLazyTx pgExecCtx Q.ReadOnly

View File

@ -17,6 +17,7 @@ import qualified Language.GraphQL.Draft.Syntax as G
import qualified Network.HTTP.Client as HTTP
import qualified Network.Wreq as Wreq
import Hasura.GraphQL.Schema.Merge
import Hasura.RQL.DDL.Headers (makeHeadersFromConf)
import Hasura.RQL.Types
import Hasura.Server.Utils (httpExceptToJSON)
@ -119,31 +120,9 @@ mergeRemoteSchema
=> GS.GCtxMap
-> GS.GCtx
-> m GS.GCtxMap
mergeRemoteSchema ctxMap mergedRemoteGCtx = do
res <- forM (Map.toList ctxMap) $ \(role, gCtx) -> do
updatedGCtx <- mergeGCtx gCtx mergedRemoteGCtx
return (role, updatedGCtx)
return $ Map.fromList res
mergeGCtx
:: (MonadError QErr m)
=> GS.GCtx
-> GS.GCtx
-> m GS.GCtx
mergeGCtx gCtx rmMergedGCtx = do
let rmTypes = GS._gTypes rmMergedGCtx
hsraTyMap = GS._gTypes gCtx
GS.checkSchemaConflicts gCtx rmMergedGCtx
let newQR = mergeQueryRoot gCtx rmMergedGCtx
newMR = mergeMutRoot gCtx rmMergedGCtx
newSR = mergeSubRoot gCtx rmMergedGCtx
newTyMap = mergeTyMaps hsraTyMap rmTypes newQR newMR
updatedGCtx = gCtx { GS._gTypes = newTyMap
, GS._gQueryRoot = newQR
, GS._gMutRoot = newMR
, GS._gSubRoot = newSR
}
return updatedGCtx
mergeRemoteSchema ctxMap mergedRemoteGCtx =
flip Map.traverseWithKey ctxMap $ \_ schemaCtx ->
for schemaCtx $ \gCtx -> mergeGCtx gCtx mergedRemoteGCtx
convRemoteGCtx :: GC.RemoteGCtx -> GS.GCtx
convRemoteGCtx rmGCtx =
@ -153,33 +132,7 @@ convRemoteGCtx rmGCtx =
, GS._gSubRoot = GC._rgSubscriptionRoot rmGCtx
}
mergeQueryRoot :: GS.GCtx -> GS.GCtx -> VT.ObjTyInfo
mergeQueryRoot a b = GS._gQueryRoot a <> GS._gQueryRoot b
mergeMutRoot :: GS.GCtx -> GS.GCtx -> Maybe VT.ObjTyInfo
mergeMutRoot a b = GS._gMutRoot a <> GS._gMutRoot b
mergeSubRoot :: GS.GCtx -> GS.GCtx -> Maybe VT.ObjTyInfo
mergeSubRoot a b = GS._gSubRoot a <> GS._gSubRoot b
mergeTyMaps
:: VT.TypeMap
-> VT.TypeMap
-> VT.ObjTyInfo
-> Maybe VT.ObjTyInfo
-> VT.TypeMap
mergeTyMaps hTyMap rmTyMap newQR newMR =
let newTyMap = hTyMap <> rmTyMap
newTyMap' =
Map.insert (G.NamedType "query_root") (VT.TIObj newQR) newTyMap
in maybe newTyMap' (\mr -> Map.insert
(G.NamedType "mutation_root")
(VT.TIObj mr) newTyMap') newMR
-- parsing the introspection query result
-- | Parsing the introspection query result
newtype FromIntrospection a
= FromIntrospection { fromIntrospection :: a }
deriving (Show, Eq, Generic)

View File

@ -18,6 +18,7 @@ module Hasura.GraphQL.Resolve
) where
import Data.Has
import Hasura.Session
import qualified Data.HashMap.Strict as Map
import qualified Database.PG.Query as Q
@ -77,9 +78,9 @@ toPGQuery = \case
validateHdrs
:: (Foldable t, QErrM m) => UserInfo -> t Text -> m ()
validateHdrs userInfo hdrs = do
let receivedVars = userVars userInfo
let receivedVars = _uiSession userInfo
forM_ hdrs $ \hdr ->
unless (isJust $ getVarVal hdr receivedVars) $
unless (isJust $ getSessionVariableValue (mkSessionVariable hdr) receivedVars) $
throw400 NotFound $ hdr <<> " header is expected but not found"
queryFldToPGAST
@ -128,7 +129,7 @@ queryFldToPGAST fld actionExecuter = do
let f = case jsonAggType of
DS.JASMultipleRows -> QRFActionExecuteList
DS.JASSingleObject -> QRFActionExecuteObject
f <$> actionExecuter (RA.resolveActionQuery fld ctx (userVars userInfo))
f <$> actionExecuter (RA.resolveActionQuery fld ctx (_uiSession userInfo))
where
outputType = _saecOutputType ctx
jsonAggType = RA.mkJsonAggSelect outputType
@ -154,13 +155,14 @@ mutFldToTx fld = do
userInfo <- asks getter
opCtx <- getOpCtx $ V._fName fld
let noRespHeaders = fmap (,[])
roleName = _uiRole userInfo
case opCtx of
MCInsert ctx -> do
validateHdrs userInfo (_iocHeaders ctx)
noRespHeaders $ RI.convertInsert (userRole userInfo) (_iocTable ctx) fld
noRespHeaders $ RI.convertInsert roleName (_iocTable ctx) fld
MCInsertOne ctx -> do
validateHdrs userInfo (_iocHeaders ctx)
noRespHeaders $ RI.convertInsertOne (userRole userInfo) (_iocTable ctx) fld
noRespHeaders $ RI.convertInsertOne roleName (_iocTable ctx) fld
MCUpdate ctx -> do
validateHdrs userInfo (_uocHeaders ctx)
noRespHeaders $ RM.convertUpdate ctx fld
@ -174,7 +176,7 @@ mutFldToTx fld = do
validateHdrs userInfo (_docHeaders ctx)
noRespHeaders $ RM.convertDeleteByPk ctx fld
MCAction ctx ->
RA.resolveActionMutation fld ctx (userVars userInfo)
RA.resolveActionMutation fld ctx (_uiSession userInfo)
getOpCtx
:: ( MonadReusability m

View File

@ -52,6 +52,7 @@ import Hasura.RQL.Types
import Hasura.RQL.Types.Run
import Hasura.Server.Utils (mkClientHeadersForward, mkSetCookieHeaders)
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import Hasura.SQL.Types
import Hasura.SQL.Value (PGScalarValue (..), pgScalarValueToJson,
toTxtValue)
@ -64,7 +65,7 @@ $(J.deriveJSON (J.aesonDrop 3 J.snakeCase) ''ActionContext)
data ActionWebhookPayload
= ActionWebhookPayload
{ _awpAction :: !ActionContext
, _awpSessionVariables :: !UserVars
, _awpSessionVariables :: !SessionVariables
, _awpInput :: !J.Value
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 4 J.snakeCase) ''ActionWebhookPayload)
@ -129,7 +130,7 @@ resolveActionMutation
)
=> Field
-> ActionMutationExecutionContext
-> UserVars
-> SessionVariables
-> m (RespTx, HTTP.ResponseHeaders)
resolveActionMutation field executionContext sessionVariables =
case executionContext of
@ -153,7 +154,7 @@ resolveActionMutationSync
)
=> Field
-> ActionExecutionContext
-> UserVars
-> SessionVariables
-> m (RespTx, HTTP.ResponseHeaders)
resolveActionMutationSync field executionContext sessionVariables = do
let inputArgs = J.toJSON $ fmap annInpValueToJson $ _fArguments field
@ -205,7 +206,7 @@ resolveActionQuery
)
=> Field
-> ActionExecutionContext
-> UserVars
-> SessionVariables
-> HTTP.Manager
-> [HTTP.Header]
-> m (RS.AnnSimpleSelG UnresolvedVal)
@ -244,7 +245,7 @@ resolveActionMutationAsync
, Has [HTTP.Header] r
)
=> Field
-> UserVars
-> SessionVariables
-> m RespTx
resolveActionMutationAsync field sessionVariables = do
reqHeaders <- asks getter
@ -333,13 +334,13 @@ resolveAsyncActionQuery userInfo selectOpCtx field = do
sessionVarsColumnInfo = PGColumnInfo (unsafePGCol "session_variables") "session_variables"
0 (PGColumnScalar PGJSONB) False Nothing
sessionVarValue = UVPG $ AnnPGVal Nothing False $ WithScalarType PGJSONB
$ PGValJSONB $ Q.JSONB $ J.toJSON $ userVars userInfo
$ PGValJSONB $ Q.JSONB $ J.toJSON $ _uiSession userInfo
sessionVarsColumnEq = BoolFld $ AVCol sessionVarsColumnInfo [AEQ True sessionVarValue]
-- For non-admin roles, accessing an async action's response should be allowed only for the user
-- who initiated the action through mutation. The action's response is accessible for a query/subscription
-- only when it's session variables are equal to that of action's.
in if isAdmin (userRole userInfo) then actionIdColumnEq
in if isAdmin (_uiRole userInfo) then actionIdColumnEq
else BoolAnd [actionIdColumnEq, sessionVarsColumnEq]
data ActionLogItem
@ -347,7 +348,7 @@ data ActionLogItem
{ _aliId :: !UUID.UUID
, _aliActionName :: !ActionName
, _aliRequestHeaders :: ![HTTP.Header]
, _aliSessionVariables :: !UserVars
, _aliSessionVariables :: !SessionVariables
, _aliInputPayload :: !J.Value
} deriving (Show, Eq)

View File

@ -8,6 +8,7 @@ import Control.Arrow ((>>>))
import Data.Has
import Hasura.EncJSON
import Hasura.Prelude
import Hasura.Session
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J
@ -455,7 +456,7 @@ insertMultipleObjects strfyNum role tn multiObjIns addCols mutOutput errP =
insertObj strfyNum role tn objIns addCols
let affRows = sum $ map fst insResps
columnValues = catMaybes $ map snd insResps
columnValues = mapMaybe snd insResps
cteExp <- mkSelCTEFromColVals tn tableColInfos columnValues
let sql = toSQL $ RR.mkMutationOutputExp tn tableColInfos (Just affRows) cteExp mutOutput strfyNum
runIdentity . Q.getRow

View File

@ -12,6 +12,7 @@ import qualified Data.HashSet as Set
import qualified Data.Text as T
import qualified Language.GraphQL.Draft.Syntax as G
import Hasura.GraphQL.Context
import Hasura.GraphQL.Resolve.Context
import Hasura.GraphQL.Resolve.InputValue
import Hasura.GraphQL.Validate.Context
@ -330,9 +331,9 @@ schemaR fld =
"__typename" -> retJT "__Schema"
"types" -> fmap J.toJSON $ mapM (namedTypeR' subFld) $
sortOn getNamedTy $ Map.elems tyMap
"queryType" -> J.toJSON <$> namedTypeR (G.NamedType "query_root") subFld
"mutationType" -> typeR' "mutation_root" subFld
"subscriptionType" -> typeR' "subscription_root" subFld
"queryType" -> J.toJSON <$> namedTypeR queryRootNamedType subFld
"mutationType" -> typeR' mutationRootNamedType subFld
"subscriptionType" -> typeR' subscriptionRootNamedType subFld
"directives" -> J.toJSON <$> mapM (directiveR subFld)
(sortOn _diName defaultDirectives)
_ -> return J.Null
@ -342,15 +343,15 @@ typeR
=> Field -> m J.Value
typeR fld = do
name <- asPGColText =<< getArg args "name"
typeR' (G.Name name) fld
typeR' (G.NamedType $ G.Name name) fld
where
args = _fArguments fld
typeR'
:: (MonadReader r m, Has TypeMap r, MonadError QErr m)
=> G.Name -> Field -> m J.Value
=> G.NamedType -> Field -> m J.Value
typeR' n fld = do
tyMap <- asks getter
case Map.lookup (G.NamedType n) tyMap of
case Map.lookup n tyMap of
Nothing -> return J.Null
Just tyInfo -> J.Object <$> namedTypeR' fld tyInfo

View File

@ -21,7 +21,7 @@ import Hasura.RQL.Types.Common
import Hasura.RQL.Types.ComputedField
import Hasura.RQL.Types.CustomTypes
import Hasura.RQL.Types.Function
import Hasura.RQL.Types.Permission
import Hasura.Session
import Hasura.SQL.Types
import Hasura.SQL.Value
@ -231,7 +231,7 @@ partialSQLExpToUnresolvedVal = \case
data UnresolvedVal
-- | an entire session variables JSON object
= UVSession
| UVSessVar !(PGType PGScalarType) !SessVar
| UVSessVar !(PGType PGScalarType) !SessionVariable
-- | a SQL value literal that can be parameterized over
| UVPG !AnnPGVal
-- | an arbitrary SQL expression, which /cannot/ be parameterized over

View File

@ -32,6 +32,7 @@ import Hasura.Prelude
import Hasura.RQL.DML.Internal (mkAdminRolePermInfo)
import Hasura.RQL.Types
import Hasura.Server.Utils (duplicates)
import Hasura.Session
import Hasura.SQL.Types
import Hasura.GraphQL.Schema.Action
@ -47,10 +48,12 @@ import Hasura.GraphQL.Schema.Mutation.Update
import Hasura.GraphQL.Schema.OrderBy
import Hasura.GraphQL.Schema.Select
type TableSchemaCtx = RoleContext (TyAgg, RootFields, InsCtxMap)
getInsPerm :: TableInfo -> RoleName -> Maybe InsPermInfo
getInsPerm tabInfo role
| role == adminRole = _permIns $ mkAdminRolePermInfo (_tiCoreInfo tabInfo)
| otherwise = Map.lookup role rolePermInfoMap >>= _permIns
getInsPerm tabInfo roleName
| roleName == adminRoleName = _permIns $ mkAdminRolePermInfo (_tiCoreInfo tabInfo)
| otherwise = Map.lookup roleName rolePermInfoMap >>= _permIns
where
rolePermInfoMap = _tiRolePermInfoMap tabInfo
@ -116,6 +119,7 @@ mkComputedFieldFunctionArgSeq inputArgs =
Seq.fromList $ procFuncArgs inputArgs faName $
\fa t -> FunctionArgItem (G.Name t) (faName fa) (faHasDefault fa)
-- see Note [Split schema generation (TODO)]
mkGCtxRole'
:: QualifiedTable
-> Maybe PGDescription
@ -321,6 +325,7 @@ mkGCtxRole' tn descM insPermM selPermM updColsM delPermM pkeyCols constraints vi
computedFieldFuncArgsInps = map (TIInpObj . fst) computedFieldReqTypes
computedFieldFuncArgScalars = Set.fromList $ concatMap snd computedFieldReqTypes
-- see Note [Split schema generation (TODO)]
getRootFldsRole'
:: QualifiedTable
-> Maybe (PrimaryKey PGColumnInfo)
@ -455,8 +460,8 @@ getRootFldsRole' tn primaryKey constraints fields funcs insM
getSelPermission :: TableInfo -> RoleName -> Maybe SelPermInfo
getSelPermission tabInfo role =
Map.lookup role (_tiRolePermInfoMap tabInfo) >>= _permSel
getSelPermission tabInfo roleName =
Map.lookup roleName (_tiRolePermInfoMap tabInfo) >>= _permSel
getSelPerm
:: (MonadError QErr m)
@ -466,11 +471,11 @@ getSelPerm
-- role and its permission
-> RoleName -> SelPermInfo
-> m (Bool, [SelField])
getSelPerm tableCache fields role selPermInfo = do
getSelPerm tableCache fields roleName selPermInfo = do
relFlds <- fmap catMaybes $ forM validRels $ \relInfo -> do
remTableInfo <- getTabInfo tableCache $ riRTable relInfo
let remTableSelPermM = getSelPermission remTableInfo role
let remTableSelPermM = getSelPermission remTableInfo roleName
remTableFlds = _tciFieldInfoMap $ _tiCoreInfo remTableInfo
remTableColGNameMap =
mkPGColGNameMap $ getValidCols remTableFlds
@ -492,7 +497,7 @@ getSelPerm tableCache fields role selPermInfo = do
CFRScalar scalarTy -> pure $ Just $ CFTScalar scalarTy
CFRSetofTable retTable -> do
retTableInfo <- getTabInfo tableCache retTable
let retTableSelPermM = getSelPermission retTableInfo role
let retTableSelPermM = getSelPermission retTableInfo roleName
retTableFlds = _tciFieldInfoMap $ _tiCoreInfo retTableInfo
retTableColGNameMap =
mkPGColGNameMap $ getValidCols retTableFlds
@ -690,34 +695,66 @@ mkGCtxMapTable
=> TableCache
-> FunctionCache
-> TableInfo
-> m (Map.HashMap RoleName (TyAgg, RootFields, InsCtxMap))
-> m (Map.HashMap RoleName TableSchemaCtx)
mkGCtxMapTable tableCache funcCache tabInfo = do
m <- flip Map.traverseWithKey rolePerms $
mkGCtxRole tableCache tn descM fields primaryKey validConstraints
tabFuncs viewInfo customConfig
m <- flip Map.traverseWithKey rolePermsMap $ \roleName rolePerm ->
for rolePerm $ mkGCtxRole tableCache tn descM fields primaryKey validConstraints
tabFuncs viewInfo customConfig roleName
adminInsCtx <- mkAdminInsCtx tableCache fields
adminSelFlds <- mkAdminSelFlds fields tableCache
let adminCtx = mkGCtxRole' tn descM (Just (cols, icRelations adminInsCtx))
(Just (True, adminSelFlds)) (Just cols) (Just ())
primaryKey validConstraints viewInfo tabFuncs
adminInsCtxMap = Map.singleton tn adminInsCtx
return $ Map.insert adminRole (adminCtx, adminRootFlds, adminInsCtxMap) m
adminTableCtx = RoleContext (adminCtx, adminRootFlds, adminInsCtxMap) Nothing
pure $ Map.insert adminRoleName adminTableCtx m
where
TableInfo coreInfo rolePerms _ = tabInfo
TableCoreInfo tn descM _ fields primaryKey _ _ viewInfo _ customConfig = coreInfo
validConstraints = mkValidConstraints $ map _cName (tciUniqueOrPrimaryKeyConstraints coreInfo)
cols = getValidCols fields
tabFuncs = filter (isValidObjectName . fiName) $
getFuncsOfTable tn funcCache
tabFuncs = filter (isValidObjectName . fiName) $ getFuncsOfTable tn funcCache
adminRootFlds =
getRootFldsRole' tn primaryKey validConstraints fields tabFuncs
(Just ([], True)) (Just (noFilter, Nothing, [], True))
(Just (cols, mempty, noFilter, Nothing, [])) (Just (noFilter, []))
viewInfo customConfig
rolePermsMap :: Map.HashMap RoleName (RoleContext RolePermInfo)
rolePermsMap = flip Map.map rolePerms $ \permInfo ->
case _permIns permInfo of
Nothing -> RoleContext permInfo Nothing
Just insPerm ->
if ipiBackendOnly insPerm then
-- Remove insert permission from 'default' context and keep it in 'backend' context.
RoleContext { _rctxDefault = permInfo{_permIns = Nothing}
, _rctxBackend = Just permInfo
}
-- Remove insert permission from 'backend' context and keep it in 'default' context.
else RoleContext { _rctxDefault = permInfo
, _rctxBackend = Just permInfo{_permIns = Nothing}
}
noFilter :: AnnBoolExpPartialSQL
noFilter = annBoolExpTrue
{- Note [Split schema generation (TODO)]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As of writing this, the schema is generated per table per role and for all permissions.
See functions "mkGCtxRole'" and "getRootFldsRole'". This approach makes hard to
differentiate schema generation for each operation (select, insert, delete and update)
based on respective permission information and safe merging those schemas eventually.
For backend-only inserts (see https://github.com/hasura/graphql-engine/pull/4224)
we need to somehow defer the logic of merging schema for inserts with others based on its
backend-only credibility. This requires significant refactor of this module and
we can't afford to do it as of now since we're going to rewrite the entire GraphQL schema
generation (see https://github.com/hasura/graphql-engine/pull/4111). For aforementioned
backend-only inserts, we're following a hacky implementation of generating schema for
both default session and one with backend privilege -- the later differs with the former by
only having the schema related to insert operation.
-}
mkGCtxMap
:: forall m. (MonadError QErr m)
=> TableCache -> FunctionCache -> ActionCache -> m GCtxMap
@ -727,47 +764,71 @@ mkGCtxMap tableCache functionCache actionCache = do
let actionsSchema = mkActionsSchema actionCache
typesMap <- combineTypes actionsSchema typesMapL
let gCtxMap = flip Map.map typesMap $
\(ty, flds, insCtxMap) -> mkGCtx ty flds insCtxMap
return gCtxMap
fmap (\(ty, flds, insCtxMap) -> mkGCtx ty flds insCtxMap)
pure gCtxMap
where
tableFltr ti = not (isSystemDefined $ _tciSystemDefined ti) && isValidObjectName (_tciName ti)
combineTypes
:: Map.HashMap RoleName (RootFields, TyAgg)
-> [Map.HashMap RoleName (TyAgg, RootFields, InsCtxMap)]
-> m (Map.HashMap RoleName (TyAgg, RootFields, InsCtxMap))
combineTypes actionsSchema maps = do
let listMap = foldr (Map.unionWith (++) . Map.map pure)
((\(rf, tyAgg) -> pure (tyAgg, rf, mempty)) <$> actionsSchema)
maps
flip Map.traverseWithKey listMap $ \_ typeList -> do
let tyAgg = mconcat $ map (^. _1) typeList
insCtx = mconcat $ map (^. _3) typeList
rootFields <- combineRootFields $ map (^. _2) typeList
pure (tyAgg, rootFields, insCtx)
-> [Map.HashMap RoleName TableSchemaCtx]
-> m (Map.HashMap RoleName TableSchemaCtx)
combineTypes actionsSchema tableCtxMaps = do
let tableCtxsMap =
foldr (Map.unionWith (++) . Map.map pure)
((\(rf, tyAgg) -> pure $ RoleContext (tyAgg, rf, mempty) Nothing) <$> actionsSchema)
tableCtxMaps
combineRootFields :: [RootFields] -> m RootFields
combineRootFields rootFields = do
let duplicateQueryFields = duplicates $
concatMap (Map.keys . _rootQueryFields) rootFields
duplicateMutationFields = duplicates $
concatMap (Map.keys . _rootMutationFields) rootFields
flip Map.traverseWithKey tableCtxsMap $ \_ tableSchemaCtxs -> do
let defaultTableSchemaCtxs = map _rctxDefault tableSchemaCtxs
backendGCtxTypesMaybe =
-- If no table has 'backend' schema context then
-- aggregated context should be Nothing
if all (isNothing . _rctxBackend) tableSchemaCtxs then Nothing
else Just $ flip map tableSchemaCtxs $
-- Consider 'default' if 'backend' doesn't exist for any table
-- see Note [Split schema generation (TODO)]
\(RoleContext def backend) -> fromMaybe def backend
-- TODO: The following exception should result in inconsistency
when (not $ null duplicateQueryFields) $
throw400 Unexpected $ "following query root fields are duplicated: "
<> showNames duplicateQueryFields
RoleContext <$> combineTypes' defaultTableSchemaCtxs
<*> mapM combineTypes' backendGCtxTypesMaybe
where
combineTypes' :: [(TyAgg, RootFields, InsCtxMap)] -> m (TyAgg, RootFields, InsCtxMap)
combineTypes' typeList = do
let tyAgg = mconcat $ map (^. _1) typeList
insCtx = mconcat $ map (^. _3) typeList
rootFields <- combineRootFields $ map (^. _2) typeList
pure (tyAgg, rootFields, insCtx)
when (not $ null duplicateMutationFields) $
throw400 Unexpected $ "following mutation root fields are duplicated: "
<> showNames duplicateMutationFields
combineRootFields :: [RootFields] -> m RootFields
combineRootFields rootFields = do
let duplicateQueryFields = duplicates $
concatMap (Map.keys . _rootQueryFields) rootFields
duplicateMutationFields = duplicates $
concatMap (Map.keys . _rootMutationFields) rootFields
pure $ mconcat rootFields
-- TODO: The following exception should result in inconsistency
when (not $ null duplicateQueryFields) $
throw400 Unexpected $ "following query root fields are duplicated: "
<> showNames duplicateQueryFields
getGCtx :: (CacheRM m) => RoleName -> GCtxMap -> m GCtx
getGCtx rn ctxMap = do
sc <- askSchemaCache
return $ fromMaybe (scDefaultRemoteGCtx sc) $ Map.lookup rn ctxMap
when (not $ null duplicateMutationFields) $
throw400 Unexpected $ "following mutation root fields are duplicated: "
<> showNames duplicateMutationFields
pure $ mconcat rootFields
getGCtx :: BackendOnlyFieldAccess -> SchemaCache -> RoleName -> GCtx
getGCtx backendOnlyFieldAccess sc roleName =
case Map.lookup roleName (scGCtxMap sc) of
Nothing -> scDefaultRemoteGCtx sc
Just (RoleContext defaultGCtx maybeBackendGCtx) ->
case backendOnlyFieldAccess of
BOFAAllowed ->
-- When backend field access is allowed and if there's no 'backend_only'
-- permissions defined, we should allow access to non backend only fields
fromMaybe defaultGCtx maybeBackendGCtx
BOFADisallowed -> defaultGCtx
-- pretty print GCtx
ppGCtx :: GCtx -> String
@ -813,12 +874,12 @@ mkGCtx tyAgg (RootFields queryFields mutationFields) insCtxMap =
colTys = Set.fromList $ map pgiType $ mapMaybe (^? _RFPGColumn) $
Map.elems fldInfos
mkMutRoot =
mkHsraObjTyInfo (Just "mutation root") (G.NamedType "mutation_root") Set.empty .
mkHsraObjTyInfo (Just "mutation root") mutationRootNamedType Set.empty .
mapFromL _fiName
mutRootM = bool (Just $ mkMutRoot mFlds) Nothing $ null mFlds
mkSubRoot =
mkHsraObjTyInfo (Just "subscription root")
(G.NamedType "subscription_root") Set.empty . mapFromL _fiName
subscriptionRootNamedType Set.empty . mapFromL _fiName
subRootM = bool (Just $ mkSubRoot qFlds) Nothing $ null qFlds
qFlds = rootFieldInfos queryFields

View File

@ -15,6 +15,7 @@ import Hasura.GraphQL.Resolve.Types
import Hasura.GraphQL.Validate.Types
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
mkAsyncActionSelectionType :: ActionName -> G.NamedType
@ -74,7 +75,7 @@ mkQueryActionField actionName actionInfo definitionList =
(_adHeaders definition)
(_adForwardClientHeaders definition)
description = mkDescriptionWith (PGDescription <$> (_aiComment actionInfo)) $
description = mkDescriptionWith (PGDescription <$> _aiComment actionInfo) $
"perform the action: " <>> actionName
fieldInfo =
@ -238,7 +239,7 @@ mkFieldMap annotatedOutputType actionInfo fieldReferences roleName =
Nothing -> Nothing
getFilterAndLimit remoteTableInfo =
if roleName == adminRole
if roleName == adminRoleName
then Just (annBoolExpTrue, Nothing)
else do
selectPermisisonInfo <-
@ -310,12 +311,12 @@ mkMutationActionSchemaOne
, (ActionMutationExecutionContext, ObjFldInfo)
, FieldMap
)
mkMutationActionSchemaOne actionInfo kind = do
mkMutationActionSchemaOne actionInfo kind =
flip Map.map permissions $ \permission ->
mkMutationActionFieldsAndTypes actionInfo permission kind
where
adminPermission = ActionPermissionInfo adminRole
permissions = Map.insert adminRole adminPermission $ _aiPermissions actionInfo
adminPermission = ActionPermissionInfo adminRoleName
permissions = Map.insert adminRoleName adminPermission $ _aiPermissions actionInfo
mkQueryActionSchemaOne
:: ActionInfo
@ -327,8 +328,8 @@ mkQueryActionSchemaOne actionInfo =
flip Map.map permissions $ \permission ->
mkQueryActionFieldsAndTypes actionInfo permission
where
adminPermission = ActionPermissionInfo adminRole
permissions = Map.insert adminRole adminPermission $ _aiPermissions actionInfo
adminPermission = ActionPermissionInfo adminRoleName
permissions = Map.insert adminRoleName adminPermission $ _aiPermissions actionInfo
mkActionsSchema
:: ActionCache

View File

@ -12,6 +12,7 @@ import Hasura.GraphQL.Context (defaultTypes)
import Hasura.GraphQL.Schema.Common (mkTableTy)
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Hasura.GraphQL.Validate.Types as VT
@ -30,7 +31,7 @@ buildObjectTypeInfo roleName annotatedObjectType =
flip map (toList $ _aotRelationships annotatedObjectType) $
\(TypeRelationship name ty remoteTableInfo _) ->
if isJust (getSelectPermissionInfoM remoteTableInfo roleName) ||
roleName == adminRole
roleName == adminRoleName
then Just (relationshipToFieldInfo name ty $ _tciName $ _tiCoreInfo remoteTableInfo)
else Nothing
where

View File

@ -1,5 +1,6 @@
module Hasura.GraphQL.Schema.Merge
( checkSchemaConflicts
( mergeGCtx
, checkSchemaConflicts
, checkConflictingNode
) where
@ -15,6 +16,33 @@ import Hasura.GraphQL.Validate.Types
import Hasura.Prelude
import Hasura.RQL.Types
mergeGCtx :: (MonadError QErr m) => GCtx -> GCtx -> m GCtx
mergeGCtx lGCtx rGCtx = do
checkSchemaConflicts lGCtx rGCtx
pure GCtx { _gTypes = mergedTypeMap
, _gFields = _gFields lGCtx <> _gFields rGCtx
, _gQueryRoot = mergedQueryRoot
, _gMutRoot = mergedMutationRoot
, _gSubRoot = mergedSubRoot
, _gOrdByCtx = _gOrdByCtx lGCtx <> _gOrdByCtx rGCtx
, _gQueryCtxMap = _gQueryCtxMap lGCtx <> _gQueryCtxMap rGCtx
, _gMutationCtxMap = _gMutationCtxMap lGCtx <> _gMutationCtxMap rGCtx
, _gInsCtxMap = _gInsCtxMap lGCtx <> _gInsCtxMap rGCtx
}
where
mergedQueryRoot = _gQueryRoot lGCtx <> _gQueryRoot rGCtx
mergedMutationRoot = _gMutRoot lGCtx <> _gMutRoot rGCtx
mergedSubRoot = _gSubRoot lGCtx <> _gSubRoot rGCtx
mergedTypeMap =
let mergedTypes = _gTypes lGCtx <> _gTypes rGCtx
modifyQueryRootField = Map.insert queryRootNamedType (TIObj mergedQueryRoot)
modifyMaybeRootField tyname maybeObj m = case maybeObj of
Nothing -> m
Just obj -> Map.insert tyname (TIObj obj) m
in modifyMaybeRootField subscriptionRootNamedType mergedSubRoot $
modifyMaybeRootField mutationRootNamedType mergedMutationRoot $
modifyQueryRootField mergedTypes
checkSchemaConflicts
:: (MonadError QErr m)
=> GCtx -> GCtx -> m ()
@ -68,8 +96,8 @@ checkSchemaConflicts gCtx remoteCtx = do
(TIInpObj t1, TIInpObj t2) -> typeEq t1 t2
_ -> False
hQRName = G.NamedType "query_root"
hMRName = G.NamedType "mutation_root"
hQRName = queryRootNamedType
hMRName = mutationRootNamedType
tyMsg ty = "types: [ " <> namesToTxt ty <>
" ] have mismatch with current graphql schema. HINT: Types must be same."
nodesMsg n = "top-level nodes: [ " <> namesToTxt n <>
@ -109,8 +137,8 @@ checkConflictingNode typeMap node = do
throw400 RemoteSchemaConflicts msg
_ -> return ()
where
hQRName = G.NamedType "query_root"
hMRName = G.NamedType "mutation_root"
hQRName = queryRootNamedType
hMRName = mutationRootNamedType
msg = "node " <> G.unName node <>
" already exists in current graphql schema"

View File

@ -15,6 +15,7 @@ import Hasura.RQL.Types
import Hasura.Server.Init.Config
import Hasura.Server.Utils (RequestId)
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import qualified Database.PG.Query as Q
import qualified Hasura.GraphQL.Execute as E
@ -75,7 +76,7 @@ runGQBatched reqId responseErrorsConfig userInfo reqHdrs reqs =
-- It's unclear what we should do if we receive multiple
-- responses with distinct headers, so just do the simplest thing
-- in this case, and don't forward any.
let includeInternal = shouldIncludeInternal (userRole userInfo) responseErrorsConfig
let includeInternal = shouldIncludeInternal (_uiRole userInfo) responseErrorsConfig
removeHeaders =
flip HttpResponse []
. encJFromList

View File

@ -51,6 +51,7 @@ import Hasura.Server.Auth (AuthMode, UserAuth
import Hasura.Server.Cors
import Hasura.Server.Utils (RequestId, getRequestId)
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import qualified Hasura.GraphQL.Execute as E
import qualified Hasura.GraphQL.Execute.LiveQuery as LQ
@ -160,7 +161,7 @@ $(J.deriveToJSON (J.aesonDrop 5 J.snakeCase) ''WsConnInfo)
data WSLogInfo
= WSLogInfo
{ _wsliUserVars :: !(Maybe UserVars)
{ _wsliUserVars :: !(Maybe SessionVariables)
, _wsliConnectionInfo :: !WsConnInfo
, _wsliEvent :: !WSEvent
} deriving (Show, Eq)
@ -176,11 +177,11 @@ instance L.ToEngineLog WSLog L.Hasura where
toEngineLog (WSLog logLevel wsLog) =
(logLevel, L.ELTWebsocketLog, J.toJSON wsLog)
mkWsInfoLog :: Maybe UserVars -> WsConnInfo -> WSEvent -> WSLog
mkWsInfoLog :: Maybe SessionVariables -> WsConnInfo -> WSEvent -> WSLog
mkWsInfoLog uv ci ev =
WSLog L.LevelInfo $ WSLogInfo uv ci ev
mkWsErrorLog :: Maybe UserVars -> WsConnInfo -> WSEvent -> WSLog
mkWsErrorLog :: Maybe SessionVariables -> WsConnInfo -> WSEvent -> WSLog
mkWsErrorLog uv ci ev =
WSLog L.LevelError $ WSLogInfo uv ci ev
@ -516,7 +517,7 @@ logWSEvent
logWSEvent (L.Logger logger) wsConn wsEv = do
userInfoME <- liftIO $ STM.readTVarIO userInfoR
let (userVarsM, tokenExpM) = case userInfoME of
CSInitialised userInfo tokenM _ -> ( Just $ userVars userInfo
CSInitialised userInfo tokenM _ -> ( Just $ _uiSession userInfo
, tokenM
)
_ -> (Nothing, Nothing)

View File

@ -7,12 +7,13 @@ module Hasura.Incremental.Internal.Dependency where
import Hasura.Prelude
import qualified Data.Dependent.Map as DM
import qualified Data.URL.Template as UT
import qualified Language.GraphQL.Draft.Syntax as G
import qualified Network.URI.Extended as N
import qualified Data.URL.Template as UT
import Control.Applicative
import Data.Aeson (Value)
import Data.CaseInsensitive (CI)
import Data.Functor.Classes (Eq1 (..), Eq2 (..))
import Data.GADT.Compare
import Data.Int
@ -170,6 +171,8 @@ instance (Cacheable k, Cacheable v) => Cacheable (HashMap k v) where
unchanged accesses = liftEq2 (unchanged accesses) (unchanged accesses)
instance (Cacheable a) => Cacheable (HashSet a) where
unchanged = liftEq . unchanged
instance (Cacheable a) => Cacheable (CI a) where
unchanged _ = (==)
instance Cacheable ()
instance (Cacheable a, Cacheable b) => Cacheable (a, b)

View File

@ -28,6 +28,7 @@ import Hasura.GraphQL.Context (defaultTypes)
import Hasura.GraphQL.Utils
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Hasura.GraphQL.Validate.Types as VT
@ -248,15 +249,15 @@ runCreateActionPermission
=> CreateActionPermission -> m EncJSON
runCreateActionPermission createActionPermission = do
actionInfo <- getActionInfo actionName
void $ onJust (Map.lookup role $ _aiPermissions actionInfo) $ const $
throw400 AlreadyExists $ "permission for role " <> role
void $ onJust (Map.lookup roleName $ _aiPermissions actionInfo) $ const $
throw400 AlreadyExists $ "permission for role " <> roleName
<<> " is already defined on " <>> actionName
persistCreateActionPermission createActionPermission
buildSchemaCacheFor $ MOActionPermission actionName role
buildSchemaCacheFor $ MOActionPermission actionName roleName
pure successMsg
where
actionName = _capAction createActionPermission
role = _capRole createActionPermission
roleName = _capRole createActionPermission
persistCreateActionPermission :: (MonadTx m) => CreateActionPermission -> m ()
persistCreateActionPermission CreateActionPermission{..}= do
@ -278,15 +279,15 @@ runDropActionPermission
=> DropActionPermission -> m EncJSON
runDropActionPermission dropActionPermission = do
actionInfo <- getActionInfo actionName
void $ onNothing (Map.lookup role $ _aiPermissions actionInfo) $
void $ onNothing (Map.lookup roleName $ _aiPermissions actionInfo) $
throw400 NotExists $
"permission for role: " <> role <<> " is not defined on " <>> actionName
liftTx $ deleteActionPermissionFromCatalog actionName role
buildSchemaCacheFor $ MOActionPermission actionName role
"permission for role: " <> roleName <<> " is not defined on " <>> actionName
liftTx $ deleteActionPermissionFromCatalog actionName roleName
buildSchemaCacheFor $ MOActionPermission actionName roleName
return successMsg
where
actionName = _dapAction dropActionPermission
role = _dapRole dropActionPermission
roleName = _dapRole dropActionPermission
deleteActionPermissionFromCatalog :: ActionName -> RoleName -> Q.TxE QErr ()
deleteActionPermissionFromCatalog actionName role =

View File

@ -248,8 +248,8 @@ runDropComputedField (DropComputedField table computedField cascade) = do
pure successMsg
where
purgeComputedFieldDependency = \case
SOTableObj qt (TOPerm role permType) | qt == table ->
liftTx $ dropPermFromCatalog qt role permType
SOTableObj qt (TOPerm roleName permType) | qt == table ->
liftTx $ dropPermFromCatalog qt roleName permType
d -> throw500 $ "unexpected dependency for computed field "
<> computedField <<> "; " <> reportSchemaObj d

View File

@ -335,10 +335,11 @@ replaceMetadataToOrdJSON ( ReplaceMetadata
insPermDefToOrdJSON :: Permission.InsPermDef -> AO.Value
insPermDefToOrdJSON = permDefToOrdJSON insPermToOrdJSON
where
insPermToOrdJSON (Permission.InsPerm check set columns) =
insPermToOrdJSON (Permission.InsPerm check set columns mBackendOnly) =
let columnsPair = ("columns",) . AO.toOrdered <$> columns
backendOnlyPair = ("backend_only",) . AO.toOrdered <$> mBackendOnly
in AO.object $ [("check", AO.toOrdered check)]
<> catMaybes [maybeSetToMaybeOrdPair set, columnsPair]
<> catMaybes [maybeSetToMaybeOrdPair set, columnsPair, backendOnlyPair]
selPermDefToOrdJSON :: Permission.SelPermDef -> AO.Value
selPermDefToOrdJSON = permDefToOrdJSON selPermToOrdJSON

View File

@ -42,6 +42,7 @@ import Hasura.Prelude
import Hasura.RQL.DDL.Permission.Internal
import Hasura.RQL.DML.Internal hiding (askPermInfo)
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Database.PG.Query as Q
@ -55,12 +56,42 @@ import qualified Data.HashMap.Strict as HM
import qualified Data.HashSet as HS
import qualified Data.Text as T
{- Note [Backend only permissions]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As of writing this note, Hasura permission system is meant to be used by the
frontend. After introducing "Actions", the webhook handlers now can make GraphQL
mutations to the server with some backend logic. These mutations shouldn't be
exposed to frontend for any user since they'll bypass the business logic.
For example:-
We've a table named "user" and it has a "email" column. We need to validate the
email address. So we define an action "create_user" and it expects the same inputs
as "insert_user" mutation (generated by Hasura). Now, a role has permission for both
actions and insert operation on the table. If the insert permission is not marked
as "backend_only: true" then it visible to the frontend client along with "creat_user".
Backend only permissions adds an additional privilege to Hasura generated operations.
Those are accessable only if the request is made with `x-hasura-admin-secret`
(if authorization is configured), `x-hasura-use-backend-only-permissions`
(value must be set to "true"), `x-hasura-role` to identify the role and other
required session variables.
backend_only `x-hasura-admin-secret` `x-hasura-use-backend-only-permissions` Result
------------ --------------------- ------------------------------------- ------
FALSE ANY ANY Mutation is always visible
TRUE FALSE ANY Mutation is always hidden
TRUE TRUE (OR NOT-SET) FALSE Mutation is hidden
TRUE TRUE (OR NOT-SET) TRUE Mutation is shown
-}
-- Insert permission
data InsPerm
= InsPerm
{ ipCheck :: !BoolExp
, ipSet :: !(Maybe (ColumnValues Value))
, ipColumns :: !(Maybe PermColSpec)
{ ipCheck :: !BoolExp
, ipSet :: !(Maybe (ColumnValues Value))
, ipColumns :: !(Maybe PermColSpec)
, ipBackendOnly :: !(Maybe Bool) -- see Note [Backend only permissions]
} deriving (Show, Eq, Lift, Generic)
instance Cacheable InsPerm
$(deriveJSON (aesonDrop 2 snakeCase){omitNothingFields=True} ''InsPerm)
@ -96,7 +127,7 @@ buildInsPermInfo
-> FieldInfoMap FieldInfo
-> PermDef InsPerm
-> m (WithDeps InsPermInfo)
buildInsPermInfo tn fieldInfoMap (PermDef _rn (InsPerm checkCond set mCols) _) =
buildInsPermInfo tn fieldInfoMap (PermDef _rn (InsPerm checkCond set mCols mBackendOnly) _) =
withPathK "permission" $ do
(be, beDeps) <- withPathK "check" $ procBoolExp tn fieldInfoMap checkCond
(setColsSQL, setHdrs, setColDeps) <- procSetObj tn fieldInfoMap set
@ -107,8 +138,9 @@ buildInsPermInfo tn fieldInfoMap (PermDef _rn (InsPerm checkCond set mCols) _) =
insColDeps = map (mkColDep DRUntyped tn) insCols
deps = mkParentDep tn : beDeps ++ setColDeps ++ insColDeps
insColsWithoutPresets = insCols \\ HM.keys setColsSQL
return (InsPermInfo (HS.fromList insColsWithoutPresets) be setColsSQL reqHdrs, deps)
return (InsPermInfo (HS.fromList insColsWithoutPresets) be setColsSQL backendOnly reqHdrs, deps)
where
backendOnly = fromMaybe False mBackendOnly
allCols = map pgiColumn $ getCols fieldInfoMap
insCols = fromMaybe allCols $ convColSpec fieldInfoMap <$> mCols
@ -202,7 +234,7 @@ data UpdPerm
{ ucColumns :: !PermColSpec -- Allowed columns
, ucSet :: !(Maybe (ColumnValues Value)) -- Preset columns
, ucFilter :: !BoolExp -- Filter expression (applied before update)
, ucCheck :: !(Maybe BoolExp)
, ucCheck :: !(Maybe BoolExp)
-- ^ Check expression, which must be true after update.
-- This is optional because we don't want to break the v1 API
-- but Nothing should be equivalent to the expression which always
@ -224,7 +256,7 @@ buildUpdPermInfo
buildUpdPermInfo tn fieldInfoMap (UpdPerm colSpec set fltr check) = do
(be, beDeps) <- withPathK "filter" $
procBoolExp tn fieldInfoMap fltr
checkExpr <- traverse (withPathK "check" . procBoolExp tn fieldInfoMap) check
(setColsSQL, setHeaders, setColDeps) <- procSetObj tn fieldInfoMap set
@ -333,7 +365,7 @@ setPermCommentTx (SetPermComment (QualifiedObject sn tn) rn pt comment) =
|] (comment, sn, tn, rn, permTypeToCode pt) True
purgePerm :: MonadTx m => QualifiedTable -> RoleName -> PermType -> m ()
purgePerm qt rn pt =
purgePerm qt rn pt =
case pt of
PTInsert -> dropPermP2 @InsPerm dp
PTSelect -> dropPermP2 @SelPerm dp

View File

@ -1,7 +1,4 @@
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeFamilyDependencies #-}
module Hasura.RQL.DDL.Permission.Internal where
@ -23,6 +20,7 @@ import Hasura.Prelude
import Hasura.RQL.GBoolExp
import Hasura.RQL.Types
import Hasura.Server.Utils
import Hasura.Session
import Hasura.SQL.Types
import Hasura.SQL.Value
@ -180,7 +178,7 @@ getDepHeadersFromVal val = case val of
where
parseOnlyString v = case v of
(String t)
| isUserVar t -> [T.toLower t]
| isSessionVariable t -> [T.toLower t]
| isReqUserId t -> [userIdHeader]
| otherwise -> []
_ -> []
@ -197,7 +195,7 @@ valueParser
valueParser pgType = \case
-- When it is a special variable
String t
| isUserVar t -> return $ mkTypedSessionVar pgType t
| isSessionVariable t -> return $ mkTypedSessionVar pgType $ mkSessionVariable t
| isReqUserId t -> return $ mkTypedSessionVar pgType userIdHeader
-- Typical value as Aeson's value
val -> case pgType of
@ -280,7 +278,7 @@ dropPermP1 dp@(DropPerm tn rn) = do
askPermInfo tabInfo rn $ getPermAcc2 dp
dropPermP2 :: forall a m. (MonadTx m, IsPerm a) => DropPerm a -> m ()
dropPermP2 dp@(DropPerm tn rn) = do
dropPermP2 dp@(DropPerm tn rn) =
liftTx $ dropPermFromCatalog tn rn pt
where
pa = getPermAcc2 dp

View File

@ -40,6 +40,7 @@ import qualified Language.GraphQL.Draft.Syntax as G
import Hasura.Db
import Hasura.GraphQL.RemoteServer
import Hasura.GraphQL.Schema.CustomTypes
import Hasura.GraphQL.Schema.Merge
import Hasura.GraphQL.Utils (showNames)
import Hasura.RQL.DDL.Action
import Hasura.RQL.DDL.ComputedField
@ -60,15 +61,15 @@ import Hasura.RQL.Types
import Hasura.RQL.Types.Catalog
import Hasura.RQL.Types.QueryCollection
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import Hasura.SQL.Types
mergeCustomTypes
:: MonadError QErr f
=> M.HashMap RoleName GS.GCtx -> GS.GCtx -> (NonObjectTypeMap, AnnotatedObjects)
=> GS.GCtxMap -> GS.GCtx -> (NonObjectTypeMap, AnnotatedObjects)
-> f (GS.GCtxMap, GS.GCtx)
mergeCustomTypes gCtxMap remoteSchemaCtx customTypesState = do
let adminCustomTypes = buildCustomTypesSchema (fst customTypesState)
(snd customTypesState) adminRole
let adminCustomTypes = uncurry buildCustomTypesSchema customTypesState adminRoleName
let commonTypes = M.intersectionWith (,) existingTypes adminCustomTypes
conflictingCustomTypes =
map (G.unNamedType . fst) $ M.toList $
@ -82,10 +83,10 @@ mergeCustomTypes gCtxMap remoteSchemaCtx customTypesState = do
"autogenerated hasura types or from remote schemas: "
<> showNames conflictingCustomTypes
let gCtxMapWithCustomTypes = flip M.mapWithKey gCtxMap $ \roleName gCtx ->
let customTypes = buildCustomTypesSchema (fst customTypesState)
(snd customTypesState) roleName
in addCustomTypes gCtx customTypes
let gCtxMapWithCustomTypes = flip M.mapWithKey gCtxMap $ \roleName schemaCtx ->
flip fmap schemaCtx $ \gCtx ->
let customTypes = uncurry buildCustomTypesSchema customTypesState roleName
in addCustomTypes gCtx customTypes
-- populate the gctx of each role with the custom types
return ( gCtxMapWithCustomTypes
@ -95,9 +96,9 @@ mergeCustomTypes gCtxMap remoteSchemaCtx customTypesState = do
addCustomTypes gCtx customTypes =
gCtx { GS._gTypes = GS._gTypes gCtx <> customTypes}
existingTypes =
case (M.lookup adminRole gCtxMap) of
Just gCtx -> GS._gTypes gCtx
Nothing -> GS._gTypes remoteSchemaCtx
case M.lookup adminRoleName gCtxMap of
Just schemaCtx -> GS._gTypes $ GC._rctxDefault schemaCtx
Nothing -> GS._gTypes remoteSchemaCtx
buildRebuildableSchemaCache
:: (HasVersion, MonadIO m, MonadUnique m, MonadTx m, HasHttpManager m, HasSQLGenCtx m)

View File

@ -21,6 +21,7 @@ import Hasura.RQL.DDL.Permission.Internal
import Hasura.RQL.DDL.Schema.Cache.Common
import Hasura.RQL.Types
import Hasura.RQL.Types.Catalog
import Hasura.Session
import Hasura.SQL.Types
buildTablePermissions
@ -92,7 +93,7 @@ buildPermission = Inc.cache proc (tableCache, tableName, tableFields, permission
(permissions >- noDuplicates mkPermissionMetadataObject)
>-> (| traverseA (\permission@(CatalogPermission _ roleName _ pDef _) ->
(| withPermission (do
bindErrorA -< when (roleName == adminRole) $
bindErrorA -< when (roleName == adminRoleName) $
throw400 ConstraintViolation "cannot define permission for admin role"
perm <- bindErrorA -< decodeValue pDef
let permDef = PermDef roleName perm Nothing

View File

@ -12,14 +12,16 @@ import Control.Arrow ((***))
import Control.Lens.Combinators
import Control.Lens.Operators
import Hasura.Prelude
import qualified Hasura.RQL.DDL.EventTrigger as DS
import Hasura.RQL.DDL.Permission
import Hasura.RQL.DDL.Permission.Internal
import Hasura.RQL.DDL.Relationship.Types
import Hasura.RQL.DDL.Schema.Catalog
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Hasura.RQL.DDL.EventTrigger as DS
import qualified Data.HashMap.Strict as M
import qualified Database.PG.Query as Q
@ -199,16 +201,16 @@ updatePermFlds refQT rn pt rename = do
updateInsPermFlds
:: (MonadTx m, CacheRM m)
=> QualifiedTable -> Rename -> RoleName -> InsPerm -> m ()
updateInsPermFlds refQT rename rn (InsPerm chk preset cols) = do
updateInsPermFlds refQT rename rn (InsPerm chk preset cols mBackendOnly) = do
updatedPerm <- case rename of
RTable rt -> do
let updChk = updateTableInBoolExp rt chk
return $ InsPerm updChk preset cols
return $ InsPerm updChk preset cols mBackendOnly
RField rf -> do
updChk <- updateFieldInBoolExp refQT rf chk
let updPresetM = updatePreset refQT rf <$> preset
updColsM = updateCols refQT rf <$> cols
return $ InsPerm updChk updPresetM updColsM
return $ InsPerm updChk updPresetM updColsM mBackendOnly
liftTx $ updatePermDefInCatalog PTInsert refQT rn updatedPerm
updateSelPermFlds

View File

@ -15,6 +15,7 @@ import Hasura.RQL.DML.Returning
import Hasura.RQL.GBoolExp
import Hasura.RQL.Instances ()
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Database.PG.Query as Q
@ -95,9 +96,9 @@ convObj prepFn defInsVals setInsVals fieldInfoMap insObj = do
preSetCols = HM.keys setInsVals
throwNotInsErr c = do
role <- userRole <$> askUserInfo
roleName <- _uiRole <$> askUserInfo
throw400 NotSupported $ "column " <> c <<> " is not insertable"
<> " for role " <>> role
<> " for role " <>> roleName
validateInpCols :: (MonadError QErr m) => [PGCol] -> [PGCol] -> m ()
validateInpCols inpCols updColsPerm = forM_ inpCols $ \inpCol ->

View File

@ -6,6 +6,7 @@ import qualified Hasura.SQL.DML as S
import Hasura.Prelude
import Hasura.RQL.GBoolExp
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Error
import Hasura.SQL.Types
import Hasura.SQL.Value
@ -38,7 +39,7 @@ mkAdminRolePermInfo ti =
getComputedFieldInfos fields
tn = _tciName ti
i = InsPermInfo (HS.fromList pgCols) annBoolExpTrue M.empty []
i = InsPermInfo (HS.fromList pgCols) annBoolExpTrue M.empty False []
s = SelPermInfo (HS.fromList pgCols) (HS.fromList scalarComputedFields) tn annBoolExpTrue
Nothing True []
u = UpdPermInfo (HS.fromList pgCols) tn annBoolExpTrue Nothing M.empty []
@ -56,7 +57,7 @@ askPermInfo' pa tableInfo = do
where
rpim = _tiRolePermInfoMap tableInfo
getRolePermInfo roleName
| roleName == adminRole = Just $ mkAdminRolePermInfo (_tiCoreInfo tableInfo)
| roleName == adminRoleName = Just $ mkAdminRolePermInfo (_tiCoreInfo tableInfo)
| otherwise = M.lookup roleName rpim
askPermInfo
@ -78,9 +79,9 @@ askPermInfo pa tableInfo = do
pt = permTypeToCode $ permAccToType pa
isTabUpdatable :: RoleName -> TableInfo -> Bool
isTabUpdatable role ti
| role == adminRole = True
| otherwise = isJust $ M.lookup role rpim >>= _permUpd
isTabUpdatable roleName ti
| roleName == adminRoleName = True
| otherwise = isJust $ M.lookup roleName rpim >>= _permUpd
where
rpim = _tiRolePermInfoMap ti
@ -124,7 +125,7 @@ checkPermOnCol pt allowedCols pgCol = do
throw400 PermissionDenied $ permErrMsg roleName
where
permErrMsg roleName
| roleName == adminRole = "no such column exists : " <>> pgCol
| roleName == adminRoleName = "no such column exists : " <>> pgCol
| otherwise = mconcat
[ "role " <>> roleName
, " does not have permission to "
@ -146,7 +147,7 @@ fetchRelTabInfo refTabName =
-- Internal error
modifyErrAndSet500 ("foreign " <> ) $ askTabInfo refTabName
type SessVarBldr m = PGType PGScalarType -> SessVar -> m S.SQLExp
type SessVarBldr m = PGType PGScalarType -> SessionVariable -> m S.SQLExp
fetchRelDet
:: (UserInfoM m, QErrM m, CacheRM m)
@ -205,11 +206,11 @@ convPartialSQLExp f = \case
PSESessVar colTy sessionVariable -> f colTy sessionVariable
sessVarFromCurrentSetting
:: (Applicative f) => PGType PGScalarType -> SessVar -> f S.SQLExp
:: (Applicative f) => PGType PGScalarType -> SessionVariable -> f S.SQLExp
sessVarFromCurrentSetting pgType sessVar =
pure $ sessVarFromCurrentSetting' pgType sessVar
sessVarFromCurrentSetting' :: PGType PGScalarType -> SessVar -> S.SQLExp
sessVarFromCurrentSetting' :: PGType PGScalarType -> SessionVariable -> S.SQLExp
sessVarFromCurrentSetting' ty sessVar =
flip S.SETyAnn (S.mkTypeAnn ty) $
case ty of
@ -217,7 +218,7 @@ sessVarFromCurrentSetting' ty sessVar =
PGTypeArray _ -> sessVarVal
where
sessVarVal = S.SEOpApp (S.SQLOp "->>")
[currentSession, S.SELit $ T.toLower sessVar]
[currentSession, S.SELit $ sessionVariableToText sessVar]
currentSession :: S.SQLExp
currentSession = S.SEUnsafe "current_setting('hasura.user')::json"
@ -278,7 +279,7 @@ toJSONableExp strfyNum colTy asText expn
-- validate headers
validateHeaders :: (UserInfoM m, QErrM m) => [T.Text] -> m ()
validateHeaders depHeaders = do
headers <- getVarNames . userVars <$> askUserInfo
headers <- getSessionVariables . _uiSession <$> askUserInfo
forM_ depHeaders $ \hdr ->
unless (hdr `elem` map T.toLower headers) $
throw400 NotFound $ hdr <<> " header is expected but not found"

View File

@ -23,6 +23,7 @@ import Hasura.RQL.DML.Returning
import Hasura.RQL.GBoolExp
import Hasura.RQL.Instances ()
import Hasura.RQL.Types
import Hasura.Session
import Hasura.SQL.Types
import qualified Database.PG.Query as Q
@ -132,9 +133,9 @@ convOp fieldInfoMap preSetCols updPerm objs conv =
allowedCols = upiCols updPerm
relWhenPgErr = "relationships can't be updated"
throwNotUpdErr c = do
role <- userRole <$> askUserInfo
roleName <- _uiRole <$> askUserInfo
throw400 NotSupported $ "column " <> c <<> " is not updatable"
<> " for role " <> role <<> "; its value is predefined in permission"
<> " for role " <> roleName <<> "; its value is predefined in permission"
validateUpdateQueryWith
:: (UserInfoM m, QErrM m, CacheRM m)

View File

@ -40,6 +40,7 @@ module Hasura.RQL.Types
import Hasura.EncJSON
import Hasura.Prelude
import Hasura.Session
import Hasura.SQL.Types
import Hasura.Db as R
@ -286,7 +287,7 @@ askFieldInfo m f =
]
askCurRole :: (UserInfoM m) => m RoleName
askCurRole = userRole <$> askUserInfo
askCurRole = _uiRole <$> askUserInfo
successMsg :: EncJSON
successMsg = "{\"message\":\"success\"}"

View File

@ -40,7 +40,7 @@ import Hasura.Incremental (Cacheable)
import Hasura.Prelude
import Hasura.RQL.DDL.Headers
import Hasura.RQL.Types.CustomTypes
import Hasura.RQL.Types.Permission
import Hasura.Session
import Hasura.SQL.Types
import Language.Haskell.TH.Syntax (Lift)

View File

@ -36,24 +36,26 @@ module Hasura.RQL.Types.BoolExp
, PreSetColsPartial
) where
import Hasura.Incremental (Cacheable)
import Hasura.Incremental (Cacheable)
import Hasura.Prelude
import Hasura.RQL.Types.Column
import Hasura.RQL.Types.Common
import Hasura.RQL.Types.Permission
import qualified Hasura.SQL.DML as S
import Hasura.Session
import Hasura.SQL.Types
import qualified Hasura.SQL.DML as S
import Control.Lens.Plated
import Control.Lens.TH
import Data.Aeson
import Data.Aeson.Casing
import Data.Aeson.Internal
import Data.Aeson.TH
import qualified Data.Aeson.Types as J
import qualified Data.HashMap.Strict as M
import Instances.TH.Lift ()
import Language.Haskell.TH.Syntax (Lift)
import Instances.TH.Lift ()
import Language.Haskell.TH.Syntax (Lift)
import qualified Data.Aeson.Types as J
import qualified Data.HashMap.Strict as M
data GExists a
= GExists
@ -341,13 +343,13 @@ type PreSetCols = M.HashMap PGCol S.SQLExp
-- doesn't resolve the session variable
data PartialSQLExp
= PSESessVar !(PGType PGScalarType) !SessVar
= PSESessVar !(PGType PGScalarType) !SessionVariable
| PSESQLExp !S.SQLExp
deriving (Show, Eq, Generic, Data)
instance NFData PartialSQLExp
instance Cacheable PartialSQLExp
mkTypedSessionVar :: PGType PGColumnType -> SessVar -> PartialSQLExp
mkTypedSessionVar :: PGType PGColumnType -> SessionVariable -> PartialSQLExp
mkTypedSessionVar columnType =
PSESessVar (unsafePGColumnToRepresentation <$> columnType)

View File

@ -35,6 +35,7 @@ import Hasura.RQL.Types.Permission
import Hasura.RQL.Types.QueryCollection
import Hasura.RQL.Types.RemoteSchema
import Hasura.RQL.Types.SchemaCache
import Hasura.Session
import Hasura.SQL.Types
newtype CatalogForeignKey

View File

@ -5,6 +5,7 @@ import qualified Data.HashMap.Strict.Extended as M
import Control.Lens hiding ((.=))
import Data.Aeson
import Hasura.Prelude
import Hasura.Session
import Hasura.RQL.Types.Action
import Hasura.RQL.Types.Common
@ -62,7 +63,7 @@ moiName objectId = moiTypeName objectId <> " " <> case objectId of
in tableObjectName <> " in " <> moiName (MOTable tableName)
MOCustomTypes -> "custom_types"
MOAction name -> dquoteTxt name
MOActionPermission name role -> dquoteTxt role <> " permission in " <> dquoteTxt name
MOActionPermission name roleName -> dquoteTxt roleName <> " permission in " <> dquoteTxt name
data MetadataObject
= MetadataObject

View File

@ -1,118 +1,23 @@
module Hasura.RQL.Types.Permission
( RoleName(..)
, roleNameToTxt
, SessVar
, SessVarVal
, UserVars
, mkUserVars
, isUserVar
, getVarNames
, getVarVal
, roleFromVars
, UserInfo(..)
, mkUserInfo
, userInfoToList
, adminUserInfo
, adminRole
, isAdmin
, PermType(..)
, permTypeToCode
, PermId(..)
) where
( PermType(..)
, permTypeToCode
, PermId(..)
) where
import Hasura.Incremental (Cacheable)
import Hasura.Prelude
import Hasura.RQL.Types.Common (NonEmptyText, adminText, mkNonEmptyText,
unNonEmptyText)
import Hasura.Server.Utils (adminSecretHeader, deprecatedAccessKeyHeader,
isUserVar, userRoleHeader)
import Hasura.Session
import Hasura.SQL.Types
import qualified Database.PG.Query as Q
import Data.Aeson
import Data.Hashable
import Instances.TH.Lift ()
import Language.Haskell.TH.Syntax (Lift)
import qualified Data.HashMap.Strict as Map
import qualified Data.Text as T
import qualified Database.PG.Query as Q
import qualified PostgreSQL.Binary.Decoding as PD
newtype RoleName
= RoleName {getRoleTxt :: NonEmptyText}
deriving ( Show, Eq, Ord, Hashable, FromJSONKey, ToJSONKey, FromJSON
, ToJSON, Q.FromCol, Q.ToPrepArg, Lift, Generic, Arbitrary, NFData, Cacheable )
instance DQuote RoleName where
dquoteTxt = roleNameToTxt
roleNameToTxt :: RoleName -> Text
roleNameToTxt = unNonEmptyText . getRoleTxt
adminRole :: RoleName
adminRole = RoleName adminText
isAdmin :: RoleName -> Bool
isAdmin = (adminRole ==)
type SessVar = Text
type SessVarVal = Text
newtype UserVars
= UserVars { unUserVars :: Map.HashMap SessVar SessVarVal}
deriving (Show, Eq, FromJSON, ToJSON, Hashable)
-- returns Nothing if x-hasura-role is an empty string
roleFromVars :: UserVars -> Maybe RoleName
roleFromVars uv =
getVarVal userRoleHeader uv >>= fmap RoleName . mkNonEmptyText
getVarVal :: SessVar -> UserVars -> Maybe SessVarVal
getVarVal k =
Map.lookup (T.toLower k) . unUserVars
getVarNames :: UserVars -> [T.Text]
getVarNames =
Map.keys . unUserVars
mkUserVars :: [(T.Text, T.Text)] -> UserVars
mkUserVars l =
UserVars $ Map.fromList
[ (T.toLower k, v)
| (k, v) <- l, isUserVar k
]
data UserInfo
= UserInfo
{ userRole :: !RoleName
, userVars :: !UserVars
} deriving (Show, Eq, Generic)
mkUserInfo :: RoleName -> UserVars -> UserInfo
mkUserInfo rn (UserVars v) =
UserInfo rn $ UserVars $ Map.insert userRoleHeader (roleNameToTxt rn) $
foldl (flip Map.delete) v [adminSecretHeader, deprecatedAccessKeyHeader]
instance Hashable UserInfo
-- $(J.deriveToJSON (J.aesonDrop 4 J.camelCase){J.omitNothingFields=True}
-- ''UserInfo
-- )
userInfoToList :: UserInfo -> [(Text, Text)]
userInfoToList userInfo =
let vars = Map.toList $ unUserVars . userVars $ userInfo
rn = roleNameToTxt . userRole $ userInfo
in (userRoleHeader, rn) : vars
adminUserInfo :: UserInfo
adminUserInfo =
mkUserInfo adminRole $ mkUserVars []
data PermType
= PTInsert
| PTSelect

View File

@ -7,6 +7,7 @@ module Hasura.RQL.Types.Run
) where
import Hasura.Prelude
import Hasura.Session
import qualified Database.PG.Query as Q
import qualified Network.HTTP.Client as HTTP

View File

@ -12,6 +12,7 @@ import Hasura.RQL.Types.Common
import Hasura.RQL.Types.ComputedField
import Hasura.RQL.Types.EventTrigger
import Hasura.RQL.Types.Permission
import Hasura.Session
import Hasura.SQL.Types
data TableObjId

View File

@ -77,8 +77,6 @@ module Hasura.RQL.Types.Table
) where
-- import qualified Hasura.GraphQL.Context as GC
import Hasura.GraphQL.Utils (showNames)
import Hasura.Incremental (Cacheable)
import Hasura.Prelude
@ -90,6 +88,7 @@ import Hasura.RQL.Types.Error
import Hasura.RQL.Types.EventTrigger
import Hasura.RQL.Types.Permission
import Hasura.Server.Utils (duplicates)
import Hasura.Session
import Hasura.SQL.Types
import Control.Lens
@ -213,6 +212,7 @@ data InsPermInfo
{ ipiCols :: !(HS.HashSet PGCol)
, ipiCheck :: !AnnBoolExpPartialSQL
, ipiSet :: !PreSetColsPartial
, ipiBackendOnly :: !Bool
, ipiRequiredHeaders :: ![T.Text]
} deriving (Show, Eq, Generic)
instance NFData InsPermInfo
@ -279,12 +279,12 @@ data EventTriggerInfo
, etiOpsDef :: !TriggerOpsDef
, etiRetryConf :: !RetryConf
, etiWebhookInfo :: !WebhookConfInfo
-- ^ The HTTP(s) URL which will be called with the event payload on configured operation.
-- Must be a POST handler. This URL can be entered manually or can be picked up from an
-- environment variable (the environment variable needs to be set before using it for
-- this configuration).
-- ^ The HTTP(s) URL which will be called with the event payload on configured operation.
-- Must be a POST handler. This URL can be entered manually or can be picked up from an
-- environment variable (the environment variable needs to be set before using it for
-- this configuration).
, etiHeaders :: ![EventHeaderInfo]
-- ^ Custom headers can be added to an event trigger. Each webhook request will have these
-- ^ Custom headers can be added to an event trigger. Each webhook request will have these
-- headers added.
} deriving (Show, Eq, Generic)
instance NFData EventTriggerInfo

View File

@ -38,6 +38,7 @@ import Hasura.RQL.Types.Run
import Hasura.Server.Init (InstanceId (..))
import Hasura.Server.Utils
import Hasura.Server.Version (HasVersion)
import Hasura.Session
data RQLQueryV1

View File

@ -54,6 +54,7 @@ import Hasura.Server.Middleware (corsMiddleware)
import Hasura.Server.Migrate (migrateCatalog)
import Hasura.Server.Utils
import Hasura.Server.Version
import Hasura.Session
import Hasura.SQL.Types
import qualified Hasura.GraphQL.Execute as E
@ -190,8 +191,8 @@ parseBody reqBody =
onlyAdmin :: (Monad m) => Handler m ()
onlyAdmin = do
uRole <- asks (userRole . hcUser)
when (uRole /= adminRole) $
uRole <- asks (_uiRole . hcUser)
when (uRole /= adminRoleName) $
throw400 AccessDenied "You have to be an admin to access this endpoint"
buildQCtx :: (MonadIO m) => Handler m QCtx
@ -234,7 +235,7 @@ mkSpockAction serverCtx qErrEncoder qErrModifier apiHandler = do
return userInfoE
let handlerState = HandlerCtx serverCtx userInfo headers requestId
includeInternal = shouldIncludeInternal (userRole userInfo) $
includeInternal = shouldIncludeInternal (_uiRole userInfo) $
scResponseInternalErrorsConfig serverCtx
(serviceTime, (result, q)) <- withElapsedTime $ case apiHandler of

View File

@ -29,13 +29,13 @@ import qualified Data.Text as T
import qualified Network.HTTP.Client as H
import qualified Network.HTTP.Types as N
import Hasura.HTTP
import Hasura.Logging
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Server.Auth.JWT
import Hasura.Server.Auth.WebHook
import Hasura.Server.Utils
import Hasura.Session
-- | Typeclass representing the @UserInfo@ authorization and resolving effect
class (Monad m) => UserAuthentication m where
@ -139,9 +139,6 @@ mkJwtCtx JWTConfig{..} httpManager logger = do
JFEJwkParseError _ e -> throwError e
JFEExpiryParseError _ _ -> return Nothing
getUserInfo
:: (HasVersion, MonadIO m, MonadError QErr m)
=> Logger Hasura
@ -160,7 +157,7 @@ getUserInfoWithExpTime
-> m (UserInfo, Maybe UTCTime)
getUserInfoWithExpTime logger manager rawHeaders = \case
AMNoAuth -> return (userInfoFromHeaders, Nothing)
AMNoAuth -> (, Nothing) <$> userInfoFromHeaders UAuthNotSet
AMAdminSecret adminScrt unAuthRole ->
case adminSecretM of
@ -183,23 +180,21 @@ getUserInfoWithExpTime logger manager rawHeaders = \case
maybe action (withNoExpTime . userInfoWhenAdminSecret ak) adminSecretM
adminSecretM= foldl1 (<|>) $
map (`getVarVal` usrVars) [adminSecretHeader, deprecatedAccessKeyHeader]
map (`getSessionVariableValue` sessionVariables) [adminSecretHeader, deprecatedAccessKeyHeader]
usrVars = mkUserVars $ hdrsToText rawHeaders
sessionVariables = mkSessionVariables rawHeaders
userInfoWhenAdminSecret key reqKey = do
when (reqKey /= getAdminSecret key) $ throw401 $
"invalid " <> adminSecretHeader <> "/" <> deprecatedAccessKeyHeader
return userInfoFromHeaders
userInfoFromHeaders UAdminSecretSent
userInfoWhenNoAdminSecret = \case
Nothing -> throw401 $ adminSecretHeader <> "/"
<> deprecatedAccessKeyHeader <> " required, but not found"
Just role -> return $ mkUserInfo role usrVars
Just roleName -> mkUserInfo UAdminSecretNotSent sessionVariables $ Just roleName
withNoExpTime a = (, Nothing) <$> a
userInfoFromHeaders =
case roleFromVars usrVars of
Just rn -> mkUserInfo rn usrVars
Nothing -> mkUserInfo adminRole usrVars
userInfoFromHeaders uas =
mkUserInfo uas sessionVariables $ Just adminRoleName

View File

@ -30,28 +30,30 @@ import Hasura.HTTP
import Hasura.Logging (Hasura, LogLevel (..), Logger (..))
import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.RQL.Types.Error (encodeJSONPath)
import Hasura.Server.Auth.JWT.Internal (parseHmacKey, parseRsaKey)
import Hasura.Server.Auth.JWT.Logging
import Hasura.Server.Utils (getRequestHeader, userRoleHeader, executeJSONPath)
import Hasura.Server.Utils (executeJSONPath, getRequestHeader,
isSessionVariable, userRoleHeader)
import Hasura.Server.Version (HasVersion)
import Hasura.RQL.Types.Error (encodeJSONPath)
import Hasura.Session
import qualified Control.Concurrent.Extended as C
import qualified Crypto.JWT as Jose
import qualified Data.Aeson as J
import qualified Data.Aeson.Casing as J
import qualified Data.Aeson.TH as J
import qualified Data.Aeson.Internal as J
import qualified Data.Aeson.TH as J
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as Map
import qualified Data.Parser.JSONPath as JSONPath
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wreq as Wreq
import qualified Data.Parser.JSONPath as JSONPath
newtype RawJWT = RawJWT BL.ByteString
@ -70,7 +72,7 @@ data JWTConfigClaims
instance J.ToJSON JWTConfigClaims where
toJSON (ClaimNsPath nsPath) = J.String . T.pack $ encodeJSONPath nsPath
toJSON (ClaimNs ns) = J.String ns
toJSON (ClaimNs ns) = J.String ns
data JWTConfig
= JWTConfig
@ -224,8 +226,8 @@ processJwt jwtCtx headers mUnAuthRole =
withoutAuthZHeader = do
unAuthRole <- maybe missingAuthzHeader return mUnAuthRole
return $ (, Nothing) $
mkUserInfo unAuthRole $ mkUserVars $ hdrsToText headers
userInfo <- mkUserInfo UAdminSecretNotSent (mkSessionVariables headers) $ Just unAuthRole
pure (userInfo, Nothing)
missingAuthzHeader =
throw400 InvalidHeaders "Missing Authorization header in JWT authentication mode"
@ -259,22 +261,22 @@ processAuthZHeader jwtCtx headers authzHeader = do
hasuraClaims <- parseObjectFromString claimsFmt hasuraClaimsV
-- filter only x-hasura claims and convert to lower-case
let claimsMap = Map.filterWithKey (\k _ -> isUserVar k)
let claimsMap = Map.filterWithKey (\k _ -> isSessionVariable k)
$ Map.fromList $ map (first T.toLower)
$ Map.toList hasuraClaims
HasuraClaims allowedRoles defaultRole <- parseHasuraClaims claimsMap
let role = getCurrentRole defaultRole
let roleName = getCurrentRole defaultRole
when (role `notElem` allowedRoles) currRoleNotAllowed
when (roleName `notElem` allowedRoles) currRoleNotAllowed
let finalClaims =
Map.delete defaultRoleClaim . Map.delete allowedRolesClaim $ claimsMap
-- transform the map of text:aeson-value -> text:text
metadata <- decodeJSON $ J.Object finalClaims
return $ (, expTimeM) $ mkUserInfo role $ mkUserVars $ Map.toList metadata
userInfo <- mkUserInfo UAdminSecretNotSent
(mkSessionVariablesText $ Map.toList metadata) $ Just roleName
pure (userInfo, expTimeM)
where
parseAuthzHeader = do
let tokenParts = BLC.words authzHeader
@ -302,7 +304,7 @@ processAuthZHeader jwtCtx headers authzHeader = do
claimsLocation =
case jcxClaimNs jwtCtx of
ClaimNsPath path -> T.pack $ "claims_namespace_path " <> encodeJSONPath path
ClaimNs ns -> "claims_namespace " <> ns
ClaimNs ns -> "claims_namespace " <> ns
claimsErr = throw400 JWTInvalidClaims
@ -312,7 +314,7 @@ processAuthZHeader jwtCtx headers authzHeader = do
-- see if there is a x-hasura-role header, or else pick the default role
getCurrentRole defaultRole =
let mUserRole = getRequestHeader userRoleHeader headers
in maybe defaultRole RoleName $ mUserRole >>= mkNonEmptyText . bsToTxt
in fromMaybe defaultRole $ mUserRole >>= mkRoleName . bsToTxt
decodeJSON val = case J.fromJSON val of
J.Error e -> throw400 JWTInvalidClaims ("x-hasura-* claims: " <> T.pack e)
@ -332,9 +334,10 @@ processAuthZHeader jwtCtx headers authzHeader = do
throw400 AccessDenied "Your current role is not in allowed roles"
claimsNotFound = do
let claimsNsError = case jcxClaimNs jwtCtx of
ClaimNsPath path -> T.pack $ "claims not found at claims_namespace_path: '" <> (encodeJSONPath path) <> "'"
ClaimNsPath path -> T.pack $ "claims not found at claims_namespace_path: '"
<> encodeJSONPath path <> "'"
ClaimNs ns -> "claims key: '" <> ns <> "' not found"
throw400 JWTInvalidClaims $ claimsNsError
throw400 JWTInvalidClaims claimsNsError
-- parse x-hasura-allowed-roles, x-hasura-default-role from JWT claims
@ -406,7 +409,7 @@ instance J.ToJSON JWTConfig where
claimsNsFields = case claimNs of
ClaimNsPath nsPath ->
["claims_namespace_path" J..= (encodeJSONPath nsPath)]
["claims_namespace_path" J..= encodeJSONPath nsPath]
ClaimNs ns -> ["claims_namespace" J..= J.String ns]
sharedFields = [ "claims_format" J..= claimsFmt

View File

@ -28,6 +28,7 @@ import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Server.Logging
import Hasura.Server.Utils
import Hasura.Session
data AuthHookType
@ -87,7 +88,7 @@ userInfoFromAuthHook logger manager hook reqHeaders = do
unLogger logger $
WebHookLog LevelError Nothing (ahUrl hook) (hookMethod hook)
(Just $ HttpException err) Nothing Nothing
throw500 $ "webhook authentication request failed"
throw500 "webhook authentication request failed"
mkUserInfoFromResp
@ -115,15 +116,12 @@ mkUserInfoFromResp (Logger logger) url method statusCode respBody
throw500 "Invalid response from authorization hook"
where
getUserInfoFromHdrs rawHeaders = do
let usrVars = mkUserVars $ Map.toList rawHeaders
case roleFromVars usrVars of
Nothing -> do
logError
throw500 "missing x-hasura-role key in webhook response"
Just rn -> do
logWebHookResp LevelInfo Nothing Nothing
expiration <- runMaybeT $ timeFromCacheControl rawHeaders <|> timeFromExpires rawHeaders
return (mkUserInfo rn usrVars, expiration)
userInfo <- mkUserInfo UAdminSecretNotSent
(mkSessionVariablesText $ Map.toList rawHeaders)
Nothing
logWebHookResp LevelInfo Nothing Nothing
expiration <- runMaybeT $ timeFromCacheControl rawHeaders <|> timeFromExpires rawHeaders
pure (userInfo, expiration)
logWebHookResp :: MonadIO m => LogLevel -> Maybe BL.ByteString -> Maybe T.Text -> m ()
logWebHookResp logLevel mResp message =

View File

@ -59,13 +59,13 @@ import qualified Hasura.Logging as L
import Hasura.Db
import Hasura.Prelude
import Hasura.RQL.Types (QErr, RoleName (..), SchemaCache (..),
mkNonEmptyText)
import Hasura.RQL.Types (QErr, SchemaCache (..))
import Hasura.Server.Auth
import Hasura.Server.Cors
import Hasura.Server.Init.Config
import Hasura.Server.Logging
import Hasura.Server.Utils
import Hasura.Session
import Network.URI (parseURI)
newtype DbUid
@ -622,7 +622,7 @@ parseConnParams =
help (snd pgTimeoutEnv)
)
allowPrepare = optional $
option (eitherReader parseStrAsBool)
option (eitherReader parseStringAsBool)
( long "use-prepared-statements" <>
metavar "<true|false>" <>
help (snd pgUsePrepareEnv)
@ -689,13 +689,13 @@ jwtSecretHelp = "The JSON containing type and the JWK used for verifying. e.g: "
<> "`{\"type\": \"RS256\", \"key\": \"<your-PEM-RSA-public-key>\", \"claims_namespace\": \"<optional-custom-claims-key-name>\"}`"
parseUnAuthRole :: Parser (Maybe RoleName)
parseUnAuthRole = fmap mkRoleName $ optional $
parseUnAuthRole = fmap mkRoleName' $ optional $
strOption ( long "unauthorized-role" <>
metavar "<ROLE>" <>
help (snd unAuthRoleEnv)
)
where
mkRoleName mText = mText >>= (fmap RoleName . mkNonEmptyText)
mkRoleName' mText = mText >>= mkRoleName
parseCorsConfig :: Parser (Maybe CorsConfig)
parseCorsConfig = mapCC <$> disableCors <*> corsDomain
@ -730,7 +730,7 @@ parseConsoleAssetsDir = optional $
parseEnableTelemetry :: Parser (Maybe Bool)
parseEnableTelemetry = optional $
option (eitherReader parseStrAsBool)
option (eitherReader parseStringAsBool)
( long "enable-telemetry" <>
help (snd enableTelemetryEnv)
)

View File

@ -18,9 +18,9 @@ import qualified Hasura.GraphQL.Execute.LiveQuery as LQ
import qualified Hasura.Logging as L
import Hasura.Prelude
import Hasura.RQL.Types (RoleName (..), isAdmin, mkNonEmptyText)
import Hasura.Server.Auth
import Hasura.Server.Cors
import Hasura.Session
data RawConnParams
= RawConnParams
@ -222,9 +222,9 @@ instance FromEnv AdminSecret where
fromEnv = Right . AdminSecret . T.pack
instance FromEnv RoleName where
fromEnv string = case mkNonEmptyText (T.pack string) of
Nothing -> Left "empty string not allowed"
Just neText -> Right $ RoleName neText
fromEnv string = case mkRoleName (T.pack string) of
Nothing -> Left "empty string not allowed"
Just roleName -> Right roleName
instance FromEnv Bool where
fromEnv = parseStrAsBool

View File

@ -41,6 +41,7 @@ import Hasura.Prelude
import Hasura.RQL.Types
import Hasura.Server.Compression
import Hasura.Server.Utils
import Hasura.Session
data StartupLog
= StartupLog
@ -191,7 +192,7 @@ instance ToJSON HttpInfoLog where
data OperationLog
= OperationLog
{ olRequestId :: !RequestId
, olUserVars :: !(Maybe UserVars)
, olUserVars :: !(Maybe SessionVariables)
, olResponseSize :: !(Maybe Int64)
, olRequestReadTime :: !(Maybe Seconds)
-- ^ Request IO wait time, i.e. time spent reading the full request from the socket.
@ -235,7 +236,7 @@ mkHttpAccessLogContext userInfoM reqId req res mTiming compressTypeM headers =
}
op = OperationLog
{ olRequestId = reqId
, olUserVars = userVars <$> userInfoM
, olUserVars = _uiSession <$> userInfoM
, olResponseSize = respSize
, olRequestReadTime = Seconds . fst <$> mTiming
, olQueryExecutionTime = Seconds . snd <$> mTiming
@ -271,7 +272,7 @@ mkHttpErrorLogContext userInfoM reqId req err query mTiming compressTypeM header
}
op = OperationLog
{ olRequestId = reqId
, olUserVars = userVars <$> userInfoM
, olUserVars = _uiSession <$> userInfoM
, olResponseSize = Just $ BL.length $ encode err
, olRequestReadTime = Seconds . fst <$> mTiming
, olQueryExecutionTime = Seconds . snd <$> mTiming

View File

@ -3,6 +3,7 @@ module Hasura.Server.SchemaUpdate
where
import Hasura.Prelude
import Hasura.Session
import Hasura.Logging
import Hasura.RQL.DDL.Schema (runCacheRWT)

View File

@ -10,10 +10,10 @@ module Hasura.Server.Telemetry
)
where
import Control.Exception (try)
import Control.Exception (try)
import Control.Lens
import Data.List
import Data.Text.Conversions (UTF8 (..), decodeText)
import Data.Text.Conversions (UTF8 (..), decodeText)
import Hasura.HTTP
import Hasura.Logging
@ -22,19 +22,20 @@ import Hasura.RQL.Types
import Hasura.Server.Init
import Hasura.Server.Telemetry.Counters
import Hasura.Server.Version
import Hasura.Session
import qualified CI
import qualified Control.Concurrent.Extended as C
import qualified Data.Aeson as A
import qualified Data.Aeson.Casing as A
import qualified Data.Aeson.TH as A
import qualified Data.ByteString.Lazy as BL
import qualified Data.HashMap.Strict as Map
import qualified Data.Text as T
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wreq as Wreq
import qualified Language.GraphQL.Draft.Syntax as G
import qualified Control.Concurrent.Extended as C
import qualified Data.Aeson as A
import qualified Data.Aeson.Casing as A
import qualified Data.Aeson.TH as A
import qualified Data.ByteString.Lazy as BL
import qualified Data.HashMap.Strict as Map
import qualified Data.Text as T
import qualified Language.GraphQL.Draft.Syntax as G
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wreq as Wreq
data RelationshipMetric
= RelationshipMetric
@ -111,7 +112,7 @@ mkPayload dbId instanceId version metrics = do
-- hours. The send time depends on when the server was started and will
-- naturally drift.
runTelemetry
:: (HasVersion)
:: HasVersion
=> Logger Hasura
-> HTTP.Manager
-> IO SchemaCache
@ -175,7 +176,7 @@ computeMetrics sc _mtServiceTimings _mtPgVersion =
countUserTables predicate = length . filter predicate $ Map.elems userTables
calcPerms :: (RolePermInfo -> Maybe a) -> [RolePermInfo] -> Int
calcPerms fn perms = length $ catMaybes $ map fn perms
calcPerms fn perms = length $ mapMaybe fn perms
permsOfTbl :: TableInfo -> [(RoleName, RolePermInfo)]
permsOfTbl = Map.toList . _tiRolePermInfoMap

View File

@ -63,11 +63,27 @@ userIdHeader = "x-hasura-user-id"
requestIdHeader :: IsString a => a
requestIdHeader = "x-request-id"
useBackendOnlyPermissionsHeader :: IsString a => a
useBackendOnlyPermissionsHeader = "x-hasura-use-backend-only-permissions"
getRequestHeader :: HTTP.HeaderName -> [HTTP.Header] -> Maybe B.ByteString
getRequestHeader hdrName hdrs = snd <$> mHeader
where
mHeader = find (\h -> fst h == hdrName) hdrs
parseStringAsBool :: String -> Either String Bool
parseStringAsBool t
| map toLower t `elem` truthVals = Right True
| map toLower t `elem` falseVals = Right False
| otherwise = Left errMsg
where
truthVals = ["true", "t", "yes", "y"]
falseVals = ["false", "f", "no", "n"]
errMsg = " Not a valid boolean text. " ++ "True values are "
++ show truthVals ++ " and False values are " ++ show falseVals
++ ". All values are case insensitive"
getRequestId :: (MonadIO m) => [HTTP.Header] -> m RequestId
getRequestId headers =
-- generate a request id for every request if the client has not sent it
@ -154,14 +170,14 @@ commonResponseHeadersIgnored =
, "Content-Type", "Content-Length"
]
isUserVar :: Text -> Bool
isUserVar = T.isPrefixOf "x-hasura-" . T.toLower
isSessionVariable :: Text -> Bool
isSessionVariable = T.isPrefixOf "x-hasura-" . T.toLower
mkClientHeadersForward :: [HTTP.Header] -> [HTTP.Header]
mkClientHeadersForward reqHeaders =
xForwardedHeaders <> (filterUserVars . filterRequestHeaders) reqHeaders
xForwardedHeaders <> (filterSessionVariables . filterRequestHeaders) reqHeaders
where
filterUserVars = filter (\(k, _) -> not $ isUserVar $ bsToTxt $ CI.original k)
filterSessionVariables = filter (\(k, _) -> not $ isSessionVariable $ bsToTxt $ CI.original k)
xForwardedHeaders = flip mapMaybe reqHeaders $ \(hdrName, hdrValue) ->
case hdrName of
"Host" -> Just ("X-Forwarded-Host", hdrValue)

View File

@ -0,0 +1,176 @@
module Hasura.Session
( RoleName
, mkRoleName
, adminRoleName
, isAdmin
, roleNameToTxt
, SessionVariable
, mkSessionVariable
, SessionVariables
, sessionVariableToText
, mkSessionVariablesText
, mkSessionVariables
, sessionVariablesToHeaders
, getSessionVariableValue
, getSessionVariables
, UserAdminSecret(..)
, UserInfo
, _uiRole
, _uiSession
, _uiBackendOnlyFieldAccess
, mkUserInfo
, adminUserInfo
, BackendOnlyFieldAccess(..)
) where
import Hasura.Incremental (Cacheable)
import Hasura.Prelude
import Hasura.RQL.Types.Common (NonEmptyText, adminText, mkNonEmptyText,
unNonEmptyText)
import Hasura.RQL.Types.Error
import Hasura.Server.Utils
import Hasura.SQL.Types
import Data.Aeson
import Instances.TH.Lift ()
import Language.Haskell.TH.Syntax (Lift)
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as Map
import qualified Data.Text as T
import qualified Database.PG.Query as Q
import qualified Network.HTTP.Types as HTTP
newtype RoleName
= RoleName {getRoleTxt :: NonEmptyText}
deriving ( Show, Eq, Ord, Hashable, FromJSONKey, ToJSONKey, FromJSON
, ToJSON, Q.FromCol, Q.ToPrepArg, Lift, Generic, Arbitrary, NFData, Cacheable )
instance DQuote RoleName where
dquoteTxt = roleNameToTxt
roleNameToTxt :: RoleName -> Text
roleNameToTxt = unNonEmptyText . getRoleTxt
mkRoleName :: Text -> Maybe RoleName
mkRoleName = fmap RoleName . mkNonEmptyText
adminRoleName :: RoleName
adminRoleName = RoleName adminText
isAdmin :: RoleName -> Bool
isAdmin = (adminRoleName ==)
newtype SessionVariable = SessionVariable {unSessionVariable :: CI.CI Text}
deriving (Show, Eq, Hashable, IsString, Cacheable, Data, NFData)
instance ToJSON SessionVariable where
toJSON = toJSON . CI.original . unSessionVariable
sessionVariableToText :: SessionVariable -> Text
sessionVariableToText = T.toLower . CI.original . unSessionVariable
mkSessionVariable :: Text -> SessionVariable
mkSessionVariable = SessionVariable . CI.mk
type SessionVariableValue = Text
newtype SessionVariables =
SessionVariables { unSessionVariables :: Map.HashMap SessionVariable SessionVariableValue}
deriving (Show, Eq, Hashable, Semigroup, Monoid)
instance ToJSON SessionVariables where
toJSON (SessionVariables varMap) =
toJSON $ Map.fromList $ map (first sessionVariableToText) $ Map.toList varMap
instance FromJSON SessionVariables where
parseJSON v = mkSessionVariablesText . Map.toList <$> parseJSON v
mkSessionVariablesText :: [(Text, Text)] -> SessionVariables
mkSessionVariablesText =
SessionVariables . Map.fromList . map (first mkSessionVariable)
mkSessionVariables :: [HTTP.Header] -> SessionVariables
mkSessionVariables =
SessionVariables
. Map.fromList
. map (first SessionVariable)
. filter (isSessionVariable . CI.original . fst) -- Only x-hasura-* headers
. map (CI.map bsToTxt *** bsToTxt)
sessionVariablesToHeaders :: SessionVariables -> [HTTP.Header]
sessionVariablesToHeaders =
map ((CI.map txtToBs . unSessionVariable) *** txtToBs)
. Map.toList
. unSessionVariables
getSessionVariables :: SessionVariables -> [Text]
getSessionVariables = map sessionVariableToText . Map.keys . unSessionVariables
getSessionVariableValue :: SessionVariable -> SessionVariables -> Maybe SessionVariableValue
getSessionVariableValue k = Map.lookup k . unSessionVariables
-- | Represent the admin secret state; whether the secret is sent
-- in the request or if actually authorization is not configured.
data UserAdminSecret
= UAdminSecretSent
| UAdminSecretNotSent
| UAuthNotSet
deriving (Show, Eq)
-- | Represents the 'X-Hasura-Use-Backend-Only-Permissions' session variable
-- and request made with 'X-Hasura-Admin-Secret' if any auth configured.
-- For more details see Note [Backend only permissions]
data BackendOnlyFieldAccess
= BOFAAllowed
| BOFADisallowed
deriving (Show, Eq, Generic)
instance Hashable BackendOnlyFieldAccess
data UserInfo
= UserInfo
{ _uiRole :: !RoleName
, _uiSession :: !SessionVariables
, _uiBackendOnlyFieldAccess :: !BackendOnlyFieldAccess
} deriving (Show, Eq, Generic)
instance Hashable UserInfo
-- | Build user info from @'SessionVariables'
mkUserInfo
:: (MonadError QErr m)
=> UserAdminSecret
-> SessionVariables
-> Maybe RoleName -- ^ Default role if x-hasura-role session variable not found
-> m UserInfo
mkUserInfo userAdminSecret sess@(SessionVariables sessVars) defaultRole = do
roleName <- onNothing (maybeRoleFromSession <|> defaultRole) $
throw400 InvalidParams $ userRoleHeader <> " not found in session variables"
-- see Note [Backend only permissions] to know more about the following logic.
backendOnlyFieldAccess <- case userAdminSecret of
UAdminSecretNotSent -> pure BOFADisallowed
UAdminSecretSent -> lookForBackendOnlyPermissionsConfig
UAuthNotSet -> lookForBackendOnlyPermissionsConfig
let modifiedSession = SessionVariables $ modifySessionVariables roleName sessVars
pure $ UserInfo roleName modifiedSession backendOnlyFieldAccess
where
-- Add x-hasura-role header and remove admin secret headers
modifySessionVariables roleName
= Map.insert userRoleHeader (roleNameToTxt roleName)
. Map.delete adminSecretHeader
. Map.delete deprecatedAccessKeyHeader
-- returns Nothing if x-hasura-role is an empty string
maybeRoleFromSession =
getSessionVariableValue userRoleHeader sess >>= mkRoleName
lookForBackendOnlyPermissionsConfig =
case getSessionVariableValue useBackendOnlyPermissionsHeader sess of
Nothing -> pure BOFADisallowed
Just varVal ->
case parseStringAsBool (T.unpack varVal) of
Left err -> throw400 BadRequest $
useBackendOnlyPermissionsHeader <> ": " <> T.pack err
Right privilege -> pure $ if privilege then BOFAAllowed else BOFADisallowed
adminUserInfo :: UserInfo
adminUserInfo = UserInfo adminRoleName mempty BOFADisallowed

View File

@ -18,12 +18,13 @@ import qualified Network.HTTP.Client.TLS as HTTP
import qualified Test.Hspec.Runner as Hspec
import Hasura.Db (PGExecCtx (..))
import Hasura.RQL.Types (SQLGenCtx (..), adminUserInfo)
import Hasura.RQL.Types (SQLGenCtx (..))
import Hasura.RQL.Types.Run
import Hasura.Server.Init (RawConnInfo, mkConnInfo, mkRawConnInfo,
parseRawConnInfo, runWithEnv)
import Hasura.Server.Migrate
import Hasura.Server.Version
import Hasura.Session (adminUserInfo)
import qualified Data.Parser.CacheControlSpec as CacheControlParser
import qualified Data.Parser.JSONPathSpec as JsonPath

View File

@ -0,0 +1,22 @@
description: As backend user without header
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: backend_user
response:
errors:
- extensions:
path: $
code: validation-failed
message: no mutations exist
query:
query: |
mutation {
insert_user(objects: [
{
name: "FooBar"
}
]){
affected_rows
}
}

View File

@ -0,0 +1,28 @@
description: As backend user with header and invalid boolean value
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: backend_user
X-Hasura-Use-Backend-Only-Permissions: random
response:
errors:
- extensions:
path: $
code: bad-request
message: 'x-hasura-use-backend-only-permissions: Not a valid boolean text. True values are
["true","t","yes","y"] and False values are ["false","f","no","n"]. All values
are case insensitive'
query:
query: |
mutation {
insert_user(objects: [
{
name: "FooBar"
}
]){
affected_rows
returning {
name
}
}
}

View File

@ -0,0 +1,26 @@
description: As backend user with header
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: backend_user
X-Hasura-Use-Backend-Only-Permissions: 'true'
response:
data:
insert_user:
affected_rows: 1
returning:
- name: FooBar
query:
query: |
mutation {
insert_user(objects: [
{
name: "FooBar"
}
]){
affected_rows
returning {
name
}
}
}

View File

@ -0,0 +1,26 @@
description: As backend user with header. This test is run only if any authorization is configured and without admin secret header.
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: backend_user
X-Hasura-Use-Backend-Only-Permissions: 'true'
response:
errors:
- extensions:
path: $
code: validation-failed
message: no mutations exist
query:
query: |
mutation {
insert_user(objects: [
{
name: "FooBar"
}
]){
affected_rows
returning {
name
}
}
}

View File

@ -463,3 +463,33 @@ args:
_where:
id: X-Hasura-User-Id
is_admin: true
- type: create_insert_permission
args:
table: user
role: backend_user
permission:
check: {}
columns: '*'
backend_only: true
set:
is_admin: true
- type: create_select_permission
args:
table: user
role: backend_user
permission:
columns: '*'
filter: {}
- type: create_insert_permission
args:
table: user
role: user
permission:
check: {}
columns: '*'
backend_only: false
set:
is_admin: false

View File

@ -0,0 +1,24 @@
description: As user with no backend privilege
url: /v1/graphql
status: 200
headers:
X-Hasura-Role: user
X-Hasura-Use-Backend-Only-Permissions: 'true'
response:
errors:
- extensions:
path: $.selectionSet.insert_user
code: validation-failed
message: "field \"insert_user\" not found in type: 'mutation_root'"
query:
query: |
mutation {
insert_user(objects: [
{
name: "FooBar"
}
]){
affected_rows
}
}

View File

@ -1,5 +1,6 @@
import pytest
from validate import check_query_f
from validate import check_query_f, check_query, get_conf_f
# Marking all tests in this module that server upgrade tests can be run
# Few of them cannot be run, which will be marked skip_server_upgrade_test
@ -173,10 +174,35 @@ class TestGraphqlInsertPermission:
def test_user_insert_account_fail(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + "/user_insert_account_fail.yaml")
def test_backend_user_insert_fail(self, hge_ctx):
check_query_admin_secret(hge_ctx, self.dir() + "/backend_user_insert_fail.yaml")
def test_backend_user_insert_pass(self, hge_ctx):
check_query_admin_secret(hge_ctx, self.dir() + "/backend_user_insert_pass.yaml")
def test_backend_user_insert_invalid_bool(self, hge_ctx):
check_query_admin_secret(hge_ctx, self.dir() + "/backend_user_insert_invalid_bool.yaml")
def test_user_with_no_backend_privilege(self, hge_ctx):
check_query_admin_secret(hge_ctx, self.dir() + "/user_with_no_backend_privilege.yaml")
def test_backend_user_no_admin_secret_fail(self, hge_ctx):
if hge_ctx.hge_key and (hge_ctx.hge_jwt_key or hge_ctx.hge_webhook):
check_query_f(hge_ctx, self.dir() + "/backend_user_no_admin_secret_fail.yaml")
else:
pytest.skip("authorization not configured, skipping the test")
@classmethod
def dir(cls):
return "queries/graphql_mutation/insert/permissions"
def check_query_admin_secret(hge_ctx, f, transport='http'):
conf = get_conf_f(f)
admin_secret = hge_ctx.hge_key
if admin_secret:
conf['headers']['x-hasura-admin-secret'] = admin_secret
check_query(hge_ctx, conf, transport, False)
@usefixtures('per_class_tests_db_state')
class TestGraphqlInsertConstraints: