From d52bfcda4ebf5ac6f4f013b2d5ed698010d72a51 Mon Sep 17 00:00:00 2001 From: Rakesh Emmadi <12475069+rakeshkky@users.noreply.github.com> Date: Fri, 24 Apr 2020 14:40:53 +0530 Subject: [PATCH] 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 * Apply suggestions from code review Co-Authored-By: Marion Schleifer * 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 b7a59c19d643520cfde6af579889e1038038438a. * generate complete schema for both 'default' and 'backend' sessions * Apply suggestions from code review Co-Authored-By: Marion Schleifer * 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 Co-authored-by: Rishichandra Wawhal Co-authored-by: rikinsk Co-authored-by: Tirumarai Selvan --- CHANGELOG.md | 13 +- console/package-lock.json | 9 + console/package.json | 1 + .../Toggle.css} | 0 .../src/components/Common/Toggle/Toggle.tsx | 5 + .../src/components/Services/Data/DataState.js | 4 + .../Services/Data/Migrations/Migrations.js | 6 +- .../Services/Data/TableCommon/TableReducer.js | 15 +- .../Services/Data/TablePermissions/Actions.js | 17 +- .../Data/TablePermissions/Permissions.js | 55 +++++- .../schema-metadata-api/permission.rst | 11 +- .../auth/authorization/permission-rules.rst | 14 ++ server/graphql-engine.cabal | 1 + server/src-lib/Hasura/App.hs | 8 +- server/src-lib/Hasura/Db.hs | 12 +- server/src-lib/Hasura/GraphQL/Context.hs | 26 ++- server/src-lib/Hasura/GraphQL/Execute.hs | 24 +-- .../Hasura/GraphQL/Execute/LiveQuery/Plan.hs | 12 +- .../Hasura/GraphQL/Execute/LiveQuery/Poll.hs | 3 +- server/src-lib/Hasura/GraphQL/Execute/Plan.hs | 1 + .../src-lib/Hasura/GraphQL/Execute/Query.hs | 30 ++- server/src-lib/Hasura/GraphQL/Explain.hs | 21 ++- server/src-lib/Hasura/GraphQL/RemoteServer.hs | 57 +----- server/src-lib/Hasura/GraphQL/Resolve.hs | 14 +- .../src-lib/Hasura/GraphQL/Resolve/Action.hs | 17 +- .../src-lib/Hasura/GraphQL/Resolve/Insert.hs | 3 +- .../Hasura/GraphQL/Resolve/Introspect.hs | 13 +- .../src-lib/Hasura/GraphQL/Resolve/Types.hs | 4 +- server/src-lib/Hasura/GraphQL/Schema.hs | 157 +++++++++++----- .../src-lib/Hasura/GraphQL/Schema/Action.hs | 15 +- .../Hasura/GraphQL/Schema/CustomTypes.hs | 3 +- server/src-lib/Hasura/GraphQL/Schema/Merge.hs | 38 +++- .../src-lib/Hasura/GraphQL/Transport/HTTP.hs | 3 +- .../Hasura/GraphQL/Transport/WebSocket.hs | 9 +- .../Hasura/Incremental/Internal/Dependency.hs | 5 +- server/src-lib/Hasura/RQL/DDL/Action.hs | 19 +- .../src-lib/Hasura/RQL/DDL/ComputedField.hs | 4 +- .../src-lib/Hasura/RQL/DDL/Metadata/Types.hs | 5 +- server/src-lib/Hasura/RQL/DDL/Permission.hs | 48 ++++- .../Hasura/RQL/DDL/Permission/Internal.hs | 10 +- server/src-lib/Hasura/RQL/DDL/Schema/Cache.hs | 21 ++- .../Hasura/RQL/DDL/Schema/Cache/Permission.hs | 3 +- .../src-lib/Hasura/RQL/DDL/Schema/Rename.hs | 10 +- server/src-lib/Hasura/RQL/DML/Insert.hs | 5 +- server/src-lib/Hasura/RQL/DML/Internal.hs | 23 +-- server/src-lib/Hasura/RQL/DML/Update.hs | 5 +- server/src-lib/Hasura/RQL/Types.hs | 3 +- server/src-lib/Hasura/RQL/Types/Action.hs | 2 +- server/src-lib/Hasura/RQL/Types/BoolExp.hs | 20 +- server/src-lib/Hasura/RQL/Types/Catalog.hs | 1 + server/src-lib/Hasura/RQL/Types/Metadata.hs | 3 +- server/src-lib/Hasura/RQL/Types/Permission.hs | 107 +---------- server/src-lib/Hasura/RQL/Types/Run.hs | 1 + .../Hasura/RQL/Types/SchemaCacheTypes.hs | 1 + server/src-lib/Hasura/RQL/Types/Table.hs | 14 +- server/src-lib/Hasura/Server/API/Query.hs | 1 + server/src-lib/Hasura/Server/App.hs | 7 +- server/src-lib/Hasura/Server/Auth.hs | 21 +-- server/src-lib/Hasura/Server/Auth/JWT.hs | 39 ++-- server/src-lib/Hasura/Server/Auth/WebHook.hs | 18 +- server/src-lib/Hasura/Server/Init.hs | 12 +- server/src-lib/Hasura/Server/Init/Config.hs | 8 +- server/src-lib/Hasura/Server/Logging.hs | 7 +- server/src-lib/Hasura/Server/SchemaUpdate.hs | 1 + server/src-lib/Hasura/Server/Telemetry.hs | 31 +-- server/src-lib/Hasura/Server/Utils.hs | 24 ++- server/src-lib/Hasura/Session.hs | 176 ++++++++++++++++++ server/src-test/Main.hs | 3 +- .../permissions/backend_user_insert_fail.yaml | 22 +++ .../backend_user_insert_invalid_bool.yaml | 28 +++ .../permissions/backend_user_insert_pass.yaml | 26 +++ .../backend_user_no_admin_secret_fail.yaml | 26 +++ .../insert/permissions/schema_setup.yaml | 30 +++ .../user_with_no_backend_privilege.yaml | 24 +++ server/tests-py/test_graphql_mutations.py | 28 ++- 75 files changed, 988 insertions(+), 475 deletions(-) rename console/src/components/Common/{ReactToggle/ReactToggleOverrides.css => Toggle/Toggle.css} (100%) create mode 100644 console/src/components/Common/Toggle/Toggle.tsx create mode 100644 server/src-lib/Hasura/Session.hs create mode 100644 server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_fail.yaml create mode 100644 server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_invalid_bool.yaml create mode 100644 server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_pass.yaml create mode 100644 server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_no_admin_secret_fail.yaml create mode 100644 server/tests-py/queries/graphql_mutation/insert/permissions/user_with_no_backend_privilege.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1a0e97730..caa817a2450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/console/package-lock.json b/console/package-lock.json index 1f3efe24668..5bba1a1446b 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -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", diff --git a/console/package.json b/console/package.json index 93e4f95f7d2..355d5406ea2 100644 --- a/console/package.json +++ b/console/package.json @@ -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", diff --git a/console/src/components/Common/ReactToggle/ReactToggleOverrides.css b/console/src/components/Common/Toggle/Toggle.css similarity index 100% rename from console/src/components/Common/ReactToggle/ReactToggleOverrides.css rename to console/src/components/Common/Toggle/Toggle.css diff --git a/console/src/components/Common/Toggle/Toggle.tsx b/console/src/components/Common/Toggle/Toggle.tsx new file mode 100644 index 00000000000..fc8c57fa48c --- /dev/null +++ b/console/src/components/Common/Toggle/Toggle.tsx @@ -0,0 +1,5 @@ +import Toggle from 'react-toggle'; +import './Toggle.css'; +import 'react-toggle/style.css'; + +export default Toggle; diff --git a/console/src/components/Services/Data/DataState.js b/console/src/components/Services/Data/DataState.js index 23fa49b0b0a..9f232887390 100644 --- a/console/src/components/Services/Data/DataState.js +++ b/console/src/components/Services/Data/DataState.js @@ -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: {}, }, }; diff --git a/console/src/components/Services/Data/Migrations/Migrations.js b/console/src/components/Services/Data/Migrations/Migrations.js index 88920908c81..4ee9dfbd3bb 100644 --- a/console/src/components/Services/Data/Migrations/Migrations.js +++ b/console/src/components/Services/Data/Migrations/Migrations.js @@ -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'; diff --git a/console/src/components/Services/Data/TableCommon/TableReducer.js b/console/src/components/Services/Data/TableCommon/TableReducer.js index 1804bff3e35..2fd170e1d3b 100644 --- a/console/src/components/Services/Data/TableCommon/TableReducer.js +++ b/console/src/components/Services/Data/TableCommon/TableReducer.js @@ -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, diff --git a/console/src/components/Services/Data/TablePermissions/Actions.js b/console/src/components/Services/Data/TablePermissions/Actions.js index d088904ef70..5f802b06b37 100644 --- a/console/src/components/Services/Data/TablePermissions/Actions.js +++ b/console/src/components/Services/Data/TablePermissions/Actions.js @@ -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) { diff --git a/console/src/components/Services/Data/TablePermissions/Permissions.js b/console/src/components/Services/Data/TablePermissions/Permissions.js index 04b88510e02..ba86190685b 100644 --- a/console/src/components/Services/Data/TablePermissions/Permissions.js +++ b/console/src/components/Services/Data/TablePermissions/Permissions.js @@ -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 = ( + + + + ) + } + return (
- {addTooltip(title, toolTip)} {sectionStatusHtml} + {addTooltip(title, toolTip)} {knowMoreHtml} {sectionStatusHtml}
); }; @@ -1812,6 +1825,43 @@ class Permissions extends Component { ); }; + const getBackendOnlySection = () => { + if (!isQueryTypeBackendOnlyCompatible(permissionsState.query)) { + return null; + } + const tooltip = ( + + When enabled, this {permissionsState.query} mutation is accessible + only via "trusted backends" + + ); + const isBackendOnly = !!( + permissionsState[permissionsState.query] && + permissionsState[permissionsState.query].backend_only + ); + const backendStatus = isBackendOnly ? 'enabled' : 'disabled'; + return ( + +
+
+ dispatch(permToggleBackendOnly())} + icons={false} + /> +
+ Allow from backends only +
+
+ ); + }; + return (
diff --git a/docs/graphql/manual/api-reference/schema-metadata-api/permission.rst b/docs/graphql/manual/api-reference/schema-metadata-api/permission.rst index b20c9bad399..9c4ca12acf7 100644 --- a/docs/graphql/manual/api-reference/schema-metadata-api/permission.rst +++ b/docs/graphql/manual/api-reference/schema-metadata-api/permission.rst @@ -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: diff --git a/docs/graphql/manual/auth/authorization/permission-rules.rst b/docs/graphql/manual/auth/authorization/permission-rules.rst index bfc5666fd5b..5b8171c0563 100644 --- a/docs/graphql/manual/auth/authorization/permission-rules.rst +++ b/docs/graphql/manual/auth/authorization/permission-rules.rst @@ -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". diff --git a/server/graphql-engine.cabal b/server/graphql-engine.cabal index dba196158ac..2c821daf70b 100644 --- a/server/graphql-engine.cabal +++ b/server/graphql-engine.cabal @@ -240,6 +240,7 @@ library , Hasura.RQL.DDL.Metadata.Generator , Hasura.RQL.DDL.Schema , Hasura.EncJSON + , Hasura.Session , Data.Aeson.Ordered diff --git a/server/src-lib/Hasura/App.hs b/server/src-lib/Hasura/App.hs index 451291bb0e6..681adf0194a 100644 --- a/server/src-lib/Hasura/App.hs +++ b/server/src-lib/Hasura/App.hs @@ -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" diff --git a/server/src-lib/Hasura/Db.hs b/server/src-lib/Hasura/Db.hs index b4138f8337b..775ec4a01c5 100644 --- a/server/src-lib/Hasura/Db.hs +++ b/server/src-lib/Hasura/Db.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Context.hs b/server/src-lib/Hasura/GraphQL/Context.hs index 792eb13338e..bcb4c248d7c 100644 --- a/server/src-lib/Hasura/GraphQL/Context.hs +++ b/server/src-lib/Hasura/GraphQL/Context.hs @@ -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 $ diff --git a/server/src-lib/Hasura/GraphQL/Execute.hs b/server/src-lib/Hasura/GraphQL/Execute.hs index 9549eb78036..6fe8fd2fd07 100644 --- a/server/src-lib/Hasura/GraphQL/Execute.hs +++ b/server/src-lib/Hasura/GraphQL/Execute.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs index 3a4b78afefd..9f446230d1e 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs index 84c8a467369..e30f9efc819 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Execute/Plan.hs b/server/src-lib/Hasura/GraphQL/Execute/Plan.hs index 5aed21d819f..2a3e9b52731 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/Plan.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/Plan.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Execute/Query.hs b/server/src-lib/Hasura/GraphQL/Execute/Query.hs index be542c77bac..49098984c2e 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/Query.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/Query.hs @@ -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) diff --git a/server/src-lib/Hasura/GraphQL/Explain.hs b/server/src-lib/Hasura/GraphQL/Explain.hs index 9a422b85a24..4de22a50bf8 100644 --- a/server/src-lib/Hasura/GraphQL/Explain.hs +++ b/server/src-lib/Hasura/GraphQL/Explain.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/RemoteServer.hs b/server/src-lib/Hasura/GraphQL/RemoteServer.hs index d33ff34ecb9..9cf5ea1abc6 100644 --- a/server/src-lib/Hasura/GraphQL/RemoteServer.hs +++ b/server/src-lib/Hasura/GraphQL/RemoteServer.hs @@ -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) diff --git a/server/src-lib/Hasura/GraphQL/Resolve.hs b/server/src-lib/Hasura/GraphQL/Resolve.hs index c59b23db735..8b4351ee1f4 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Resolve/Action.hs b/server/src-lib/Hasura/GraphQL/Resolve/Action.hs index 843cc8a26ca..9fca24d44da 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve/Action.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve/Action.hs @@ -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) diff --git a/server/src-lib/Hasura/GraphQL/Resolve/Insert.hs b/server/src-lib/Hasura/GraphQL/Resolve/Insert.hs index 7b0d614ff4d..13e83c5ec5a 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve/Insert.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve/Insert.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Resolve/Introspect.hs b/server/src-lib/Hasura/GraphQL/Resolve/Introspect.hs index 710c46423ba..436f7a85d52 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve/Introspect.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve/Introspect.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Resolve/Types.hs b/server/src-lib/Hasura/GraphQL/Resolve/Types.hs index 64c31e88fd5..6a4de509439 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve/Types.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve/Types.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Schema.hs b/server/src-lib/Hasura/GraphQL/Schema.hs index 6e80216c7be..71099e8b533 100644 --- a/server/src-lib/Hasura/GraphQL/Schema.hs +++ b/server/src-lib/Hasura/GraphQL/Schema.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Schema/Action.hs b/server/src-lib/Hasura/GraphQL/Schema/Action.hs index b8aaafb7011..5fc2f5089ad 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Action.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Action.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Schema/CustomTypes.hs b/server/src-lib/Hasura/GraphQL/Schema/CustomTypes.hs index bd313b4a703..e6d227e9205 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/CustomTypes.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/CustomTypes.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Schema/Merge.hs b/server/src-lib/Hasura/GraphQL/Schema/Merge.hs index 0c5038e7f81..0ec2b7d5d49 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Merge.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Merge.hs @@ -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" diff --git a/server/src-lib/Hasura/GraphQL/Transport/HTTP.hs b/server/src-lib/Hasura/GraphQL/Transport/HTTP.hs index b9cd83b89f7..eeae95f30bf 100644 --- a/server/src-lib/Hasura/GraphQL/Transport/HTTP.hs +++ b/server/src-lib/Hasura/GraphQL/Transport/HTTP.hs @@ -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 diff --git a/server/src-lib/Hasura/GraphQL/Transport/WebSocket.hs b/server/src-lib/Hasura/GraphQL/Transport/WebSocket.hs index 530ca17f376..f0f5a29cc3b 100644 --- a/server/src-lib/Hasura/GraphQL/Transport/WebSocket.hs +++ b/server/src-lib/Hasura/GraphQL/Transport/WebSocket.hs @@ -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) diff --git a/server/src-lib/Hasura/Incremental/Internal/Dependency.hs b/server/src-lib/Hasura/Incremental/Internal/Dependency.hs index dbefec25830..25c7b18b644 100644 --- a/server/src-lib/Hasura/Incremental/Internal/Dependency.hs +++ b/server/src-lib/Hasura/Incremental/Internal/Dependency.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/DDL/Action.hs b/server/src-lib/Hasura/RQL/DDL/Action.hs index 7834d9e3f20..5c0d09b66ac 100644 --- a/server/src-lib/Hasura/RQL/DDL/Action.hs +++ b/server/src-lib/Hasura/RQL/DDL/Action.hs @@ -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 = diff --git a/server/src-lib/Hasura/RQL/DDL/ComputedField.hs b/server/src-lib/Hasura/RQL/DDL/ComputedField.hs index 879d572497a..a0578c317ab 100644 --- a/server/src-lib/Hasura/RQL/DDL/ComputedField.hs +++ b/server/src-lib/Hasura/RQL/DDL/ComputedField.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DDL/Metadata/Types.hs b/server/src-lib/Hasura/RQL/DDL/Metadata/Types.hs index 78dc157d472..4e10b9dade3 100644 --- a/server/src-lib/Hasura/RQL/DDL/Metadata/Types.hs +++ b/server/src-lib/Hasura/RQL/DDL/Metadata/Types.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DDL/Permission.hs b/server/src-lib/Hasura/RQL/DDL/Permission.hs index 433a4c2f58c..d52e601a54f 100644 --- a/server/src-lib/Hasura/RQL/DDL/Permission.hs +++ b/server/src-lib/Hasura/RQL/DDL/Permission.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DDL/Permission/Internal.hs b/server/src-lib/Hasura/RQL/DDL/Permission/Internal.hs index 33b8fc77afc..1116186adba 100644 --- a/server/src-lib/Hasura/RQL/DDL/Permission/Internal.hs +++ b/server/src-lib/Hasura/RQL/DDL/Permission/Internal.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DDL/Schema/Cache.hs b/server/src-lib/Hasura/RQL/DDL/Schema/Cache.hs index 1a4f7790c54..c8325f22727 100644 --- a/server/src-lib/Hasura/RQL/DDL/Schema/Cache.hs +++ b/server/src-lib/Hasura/RQL/DDL/Schema/Cache.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/DDL/Schema/Cache/Permission.hs b/server/src-lib/Hasura/RQL/DDL/Schema/Cache/Permission.hs index 0d546f2da0e..4e3bf3bf562 100644 --- a/server/src-lib/Hasura/RQL/DDL/Schema/Cache/Permission.hs +++ b/server/src-lib/Hasura/RQL/DDL/Schema/Cache/Permission.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DDL/Schema/Rename.hs b/server/src-lib/Hasura/RQL/DDL/Schema/Rename.hs index 1d5af974eae..7645cb6a760 100644 --- a/server/src-lib/Hasura/RQL/DDL/Schema/Rename.hs +++ b/server/src-lib/Hasura/RQL/DDL/Schema/Rename.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/DML/Insert.hs b/server/src-lib/Hasura/RQL/DML/Insert.hs index ca582d5d2a9..9e9c84253c0 100644 --- a/server/src-lib/Hasura/RQL/DML/Insert.hs +++ b/server/src-lib/Hasura/RQL/DML/Insert.hs @@ -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 -> diff --git a/server/src-lib/Hasura/RQL/DML/Internal.hs b/server/src-lib/Hasura/RQL/DML/Internal.hs index 6d221238739..26fed0073b3 100644 --- a/server/src-lib/Hasura/RQL/DML/Internal.hs +++ b/server/src-lib/Hasura/RQL/DML/Internal.hs @@ -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" diff --git a/server/src-lib/Hasura/RQL/DML/Update.hs b/server/src-lib/Hasura/RQL/DML/Update.hs index 8cc76c29c73..50cf71e9a04 100644 --- a/server/src-lib/Hasura/RQL/DML/Update.hs +++ b/server/src-lib/Hasura/RQL/DML/Update.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/Types.hs b/server/src-lib/Hasura/RQL/Types.hs index 990d64abc62..d4c6de99a9f 100644 --- a/server/src-lib/Hasura/RQL/Types.hs +++ b/server/src-lib/Hasura/RQL/Types.hs @@ -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\"}" diff --git a/server/src-lib/Hasura/RQL/Types/Action.hs b/server/src-lib/Hasura/RQL/Types/Action.hs index c09baebc1de..7bfdcc58cf1 100644 --- a/server/src-lib/Hasura/RQL/Types/Action.hs +++ b/server/src-lib/Hasura/RQL/Types/Action.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/Types/BoolExp.hs b/server/src-lib/Hasura/RQL/Types/BoolExp.hs index d37cd857e9d..65bd1c7e87d 100644 --- a/server/src-lib/Hasura/RQL/Types/BoolExp.hs +++ b/server/src-lib/Hasura/RQL/Types/BoolExp.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/Types/Catalog.hs b/server/src-lib/Hasura/RQL/Types/Catalog.hs index e69b6208b90..b69a7c2c6dd 100644 --- a/server/src-lib/Hasura/RQL/Types/Catalog.hs +++ b/server/src-lib/Hasura/RQL/Types/Catalog.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/Types/Metadata.hs b/server/src-lib/Hasura/RQL/Types/Metadata.hs index e716d4393a5..e70ffbf543d 100644 --- a/server/src-lib/Hasura/RQL/Types/Metadata.hs +++ b/server/src-lib/Hasura/RQL/Types/Metadata.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/Types/Permission.hs b/server/src-lib/Hasura/RQL/Types/Permission.hs index e97bdcfdcac..e7a3994aaa6 100644 --- a/server/src-lib/Hasura/RQL/Types/Permission.hs +++ b/server/src-lib/Hasura/RQL/Types/Permission.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/Types/Run.hs b/server/src-lib/Hasura/RQL/Types/Run.hs index c73f4d55422..bbbc35e3828 100644 --- a/server/src-lib/Hasura/RQL/Types/Run.hs +++ b/server/src-lib/Hasura/RQL/Types/Run.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/Types/SchemaCacheTypes.hs b/server/src-lib/Hasura/RQL/Types/SchemaCacheTypes.hs index 4b0fca697b9..2fe34ae3a34 100644 --- a/server/src-lib/Hasura/RQL/Types/SchemaCacheTypes.hs +++ b/server/src-lib/Hasura/RQL/Types/SchemaCacheTypes.hs @@ -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 diff --git a/server/src-lib/Hasura/RQL/Types/Table.hs b/server/src-lib/Hasura/RQL/Types/Table.hs index 5e866fed3fd..ec71cbd5085 100644 --- a/server/src-lib/Hasura/RQL/Types/Table.hs +++ b/server/src-lib/Hasura/RQL/Types/Table.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/API/Query.hs b/server/src-lib/Hasura/Server/API/Query.hs index 054782e6478..68f3f99f3b0 100644 --- a/server/src-lib/Hasura/Server/API/Query.hs +++ b/server/src-lib/Hasura/Server/API/Query.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/App.hs b/server/src-lib/Hasura/Server/App.hs index 8f6498e76ad..81319c8ddac 100644 --- a/server/src-lib/Hasura/Server/App.hs +++ b/server/src-lib/Hasura/Server/App.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/Auth.hs b/server/src-lib/Hasura/Server/Auth.hs index 3d03864d17b..d7e957ed272 100644 --- a/server/src-lib/Hasura/Server/Auth.hs +++ b/server/src-lib/Hasura/Server/Auth.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/Auth/JWT.hs b/server/src-lib/Hasura/Server/Auth/JWT.hs index b4568005aac..0b606ee1593 100644 --- a/server/src-lib/Hasura/Server/Auth/JWT.hs +++ b/server/src-lib/Hasura/Server/Auth/JWT.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/Auth/WebHook.hs b/server/src-lib/Hasura/Server/Auth/WebHook.hs index a3af838564d..a5ad0675824 100644 --- a/server/src-lib/Hasura/Server/Auth/WebHook.hs +++ b/server/src-lib/Hasura/Server/Auth/WebHook.hs @@ -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 = diff --git a/server/src-lib/Hasura/Server/Init.hs b/server/src-lib/Hasura/Server/Init.hs index 0f203728011..2fbf09b88ff 100644 --- a/server/src-lib/Hasura/Server/Init.hs +++ b/server/src-lib/Hasura/Server/Init.hs @@ -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 "" <> help (snd pgUsePrepareEnv) @@ -689,13 +689,13 @@ jwtSecretHelp = "The JSON containing type and the JWK used for verifying. e.g: " <> "`{\"type\": \"RS256\", \"key\": \"\", \"claims_namespace\": \"\"}`" parseUnAuthRole :: Parser (Maybe RoleName) -parseUnAuthRole = fmap mkRoleName $ optional $ +parseUnAuthRole = fmap mkRoleName' $ optional $ strOption ( long "unauthorized-role" <> metavar "" <> 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) ) diff --git a/server/src-lib/Hasura/Server/Init/Config.hs b/server/src-lib/Hasura/Server/Init/Config.hs index a3842a8de30..57ce09a23ac 100644 --- a/server/src-lib/Hasura/Server/Init/Config.hs +++ b/server/src-lib/Hasura/Server/Init/Config.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/Logging.hs b/server/src-lib/Hasura/Server/Logging.hs index beab234de11..77a415b3157 100644 --- a/server/src-lib/Hasura/Server/Logging.hs +++ b/server/src-lib/Hasura/Server/Logging.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/SchemaUpdate.hs b/server/src-lib/Hasura/Server/SchemaUpdate.hs index 1390b93c6d6..b9153ffbd65 100644 --- a/server/src-lib/Hasura/Server/SchemaUpdate.hs +++ b/server/src-lib/Hasura/Server/SchemaUpdate.hs @@ -3,6 +3,7 @@ module Hasura.Server.SchemaUpdate where import Hasura.Prelude +import Hasura.Session import Hasura.Logging import Hasura.RQL.DDL.Schema (runCacheRWT) diff --git a/server/src-lib/Hasura/Server/Telemetry.hs b/server/src-lib/Hasura/Server/Telemetry.hs index c0b604fb0e4..41f420541d1 100644 --- a/server/src-lib/Hasura/Server/Telemetry.hs +++ b/server/src-lib/Hasura/Server/Telemetry.hs @@ -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 diff --git a/server/src-lib/Hasura/Server/Utils.hs b/server/src-lib/Hasura/Server/Utils.hs index c18c3deb9d2..aa945dafba1 100644 --- a/server/src-lib/Hasura/Server/Utils.hs +++ b/server/src-lib/Hasura/Server/Utils.hs @@ -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) diff --git a/server/src-lib/Hasura/Session.hs b/server/src-lib/Hasura/Session.hs new file mode 100644 index 00000000000..481f796068f --- /dev/null +++ b/server/src-lib/Hasura/Session.hs @@ -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 diff --git a/server/src-test/Main.hs b/server/src-test/Main.hs index c1f3cfc9bda..f6c87130713 100644 --- a/server/src-test/Main.hs +++ b/server/src-test/Main.hs @@ -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 diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_fail.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_fail.yaml new file mode 100644 index 00000000000..6adc3efd7e5 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_fail.yaml @@ -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 + } + } diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_invalid_bool.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_invalid_bool.yaml new file mode 100644 index 00000000000..3f1a4cff987 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_invalid_bool.yaml @@ -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 + } + } + } diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_pass.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_pass.yaml new file mode 100644 index 00000000000..55d06f688e9 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_insert_pass.yaml @@ -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 + } + } + } diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_no_admin_secret_fail.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_no_admin_secret_fail.yaml new file mode 100644 index 00000000000..f7b6abc11df --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/backend_user_no_admin_secret_fail.yaml @@ -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 + } + } + } diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/schema_setup.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/schema_setup.yaml index 4dfe8cc8ef8..b4bd18a5236 100644 --- a/server/tests-py/queries/graphql_mutation/insert/permissions/schema_setup.yaml +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/schema_setup.yaml @@ -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 diff --git a/server/tests-py/queries/graphql_mutation/insert/permissions/user_with_no_backend_privilege.yaml b/server/tests-py/queries/graphql_mutation/insert/permissions/user_with_no_backend_privilege.yaml new file mode 100644 index 00000000000..690c52ee9ea --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/insert/permissions/user_with_no_backend_privilege.yaml @@ -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 + } + } diff --git a/server/tests-py/test_graphql_mutations.py b/server/tests-py/test_graphql_mutations.py index 09aff322cf9..23d0819968a 100644 --- a/server/tests-py/test_graphql_mutations.py +++ b/server/tests-py/test_graphql_mutations.py @@ -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: