From 8c4509f57dc18a9ca18586a095018ba0558bc2a0 Mon Sep 17 00:00:00 2001 From: Bobbie Soedirgo Date: Tue, 20 Jun 2023 15:48:33 +0800 Subject: [PATCH] feat: column privileges --- src/lib/PostgresMeta.ts | 5 +- src/lib/PostgresMetaColumnPrivileges.ts | 141 ++++++++++++++++ src/lib/sql/column_privileges.sql | 145 +++++++++++++++++ src/lib/sql/index.ts | 3 +- src/lib/types.ts | 48 ++++++ src/server/routes/column-privileges.ts | 131 +++++++++++++++ src/server/routes/index.ts | 2 + test/index.test.ts | 1 + test/server/column-privileges.ts | 208 ++++++++++++++++++++++++ 9 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 src/lib/PostgresMetaColumnPrivileges.ts create mode 100644 src/lib/sql/column_privileges.sql create mode 100644 src/server/routes/column-privileges.ts create mode 100644 test/server/column-privileges.ts diff --git a/src/lib/PostgresMeta.ts b/src/lib/PostgresMeta.ts index c6a594dd..86522207 100644 --- a/src/lib/PostgresMeta.ts +++ b/src/lib/PostgresMeta.ts @@ -1,5 +1,6 @@ import { PoolConfig } from 'pg' import * as Parser from './Parser.js' +import PostgresMetaColumnPrivileges from './PostgresMetaColumnPrivileges.js' import PostgresMetaColumns from './PostgresMetaColumns.js' import PostgresMetaConfig from './PostgresMetaConfig.js' import PostgresMetaExtensions from './PostgresMetaExtensions.js' @@ -23,6 +24,7 @@ import { PostgresMetaResult } from './types.js' export default class PostgresMeta { query: (sql: string) => Promise> end: () => Promise + columnPrivileges: PostgresMetaColumnPrivileges columns: PostgresMetaColumns config: PostgresMetaConfig extensions: PostgresMetaExtensions @@ -34,8 +36,8 @@ export default class PostgresMeta { relationships: PostgresMetaRelationships roles: PostgresMetaRoles schemas: PostgresMetaSchemas - tables: PostgresMetaTables tablePrivileges: PostgresMetaTablePrivileges + tables: PostgresMetaTables triggers: PostgresMetaTriggers types: PostgresMetaTypes version: PostgresMetaVersion @@ -49,6 +51,7 @@ export default class PostgresMeta { const { query, end } = init(config) this.query = query this.end = end + this.columnPrivileges = new PostgresMetaColumnPrivileges(this.query) this.columns = new PostgresMetaColumns(this.query) this.config = new PostgresMetaConfig(this.query) this.extensions = new PostgresMetaExtensions(this.query) diff --git a/src/lib/PostgresMetaColumnPrivileges.ts b/src/lib/PostgresMetaColumnPrivileges.ts new file mode 100644 index 00000000..bd3b4fe8 --- /dev/null +++ b/src/lib/PostgresMetaColumnPrivileges.ts @@ -0,0 +1,141 @@ +import { ident, literal } from 'pg-format' +import { DEFAULT_SYSTEM_SCHEMAS } from './constants.js' +import { filterByList } from './helpers.js' +import { columnPrivilegesSql } from './sql/index.js' +import { + PostgresMetaResult, + PostgresColumnPrivileges, + PostgresColumnPrivilegesGrant, + PostgresColumnPrivilegesRevoke, +} from './types.js' + +export default class PostgresMetaColumnPrivileges { + query: (sql: string) => Promise> + + constructor(query: (sql: string) => Promise>) { + this.query = query + } + + async list({ + includeSystemSchemas = false, + includedSchemas, + excludedSchemas, + limit, + offset, + }: { + includeSystemSchemas?: boolean + includedSchemas?: string[] + excludedSchemas?: string[] + limit?: number + offset?: number + } = {}): Promise> { + let sql = ` +with column_privileges as (${columnPrivilegesSql}) +select * +from column_privileges +` + const filter = filterByList( + includedSchemas, + excludedSchemas, + !includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined + ) + if (filter) { + sql += ` where relation_schema ${filter}` + } + if (limit) { + sql += ` limit ${limit}` + } + if (offset) { + sql += ` offset ${offset}` + } + return await this.query(sql) + } + + async grant( + grants: PostgresColumnPrivilegesGrant[] + ): Promise> { + let sql = ` +do $$ +declare + col record; +begin +${grants + .map(({ privilege_type, column_id, grantee, is_grantable }) => { + const [relationId, columnNumber] = column_id.split('.') + return ` +select * +from pg_attribute a +where a.attrelid = ${literal(relationId)} + and a.attnum = ${literal(columnNumber)} +into col; +execute format( + 'grant ${privilege_type} (%I) on %I to ${ + grantee.toLowerCase() === 'public' ? 'public' : ident(grantee) + } ${is_grantable ? 'with grant option' : ''}', + col.attname, + col.attrelid::regclass +);` + }) + .join('\n')} +end $$; +` + const { data, error } = await this.query(sql) + if (error) { + return { data, error } + } + + // Return the updated column privileges for modified columns. + const columnIds = [...new Set(grants.map(({ column_id }) => column_id))] + sql = ` +with column_privileges as (${columnPrivilegesSql}) +select * +from column_privileges +where column_id in (${columnIds.map(literal).join(',')}) +` + return await this.query(sql) + } + + async revoke( + revokes: PostgresColumnPrivilegesRevoke[] + ): Promise> { + let sql = ` +do $$ +declare + col record; +begin +${revokes + .map(({ privilege_type, column_id, grantee }) => { + const [relationId, columnNumber] = column_id.split('.') + return ` +select * +from pg_attribute a +where a.attrelid = ${literal(relationId)} + and a.attnum = ${literal(columnNumber)} +into col; +execute format( + 'revoke ${privilege_type} (%I) on %I from ${ + grantee.toLowerCase() === 'public' ? 'public' : ident(grantee) + }', + col.attname, + col.attrelid::regclass +);` + }) + .join('\n')} +end $$; +` + const { data, error } = await this.query(sql) + if (error) { + return { data, error } + } + + // Return the updated column privileges for modified columns. + const columnIds = [...new Set(revokes.map(({ column_id }) => column_id))] + sql = ` +with column_privileges as (${columnPrivilegesSql}) +select * +from column_privileges +where column_id in (${columnIds.map(literal).join(',')}) +` + return await this.query(sql) + } +} diff --git a/src/lib/sql/column_privileges.sql b/src/lib/sql/column_privileges.sql new file mode 100644 index 00000000..8540c583 --- /dev/null +++ b/src/lib/sql/column_privileges.sql @@ -0,0 +1,145 @@ +-- Lists each column's privileges in the form of: +-- +-- [ +-- { +-- "column_id": "12345.1", +-- "relation_schema": "public", +-- "relation_name": "mytable", +-- "column_name": "mycolumn", +-- "privileges": [ +-- { +-- "grantor": "postgres", +-- "grantee": "myrole", +-- "privilege_type": "SELECT", +-- "is_grantable": false +-- }, +-- ... +-- ] +-- }, +-- ... +-- ] +-- +-- Modified from information_schema.column_privileges. We try to be as close as +-- possible to the view definition, obtained from: +-- +-- select pg_get_viewdef('information_schema.column_privileges'); +-- +-- The main differences are: +-- - we include column privileges for materialized views +-- (reason for exclusion in information_schema.column_privileges: +-- https://www.postgresql.org/message-id/9136.1502740844%40sss.pgh.pa.us) +-- - we query a.attrelid and a.attnum to generate `column_id` +-- - `table_catalog` is omitted +-- - table_schema -> relation_schema, table_name -> relation_name +-- +-- Column privileges are intertwined with table privileges in that table +-- privileges override column privileges. E.g. if we do: +-- +-- grant all on mytable to myrole; +-- +-- Then `myrole` is granted privileges for ALL columns. Likewise, if we do: +-- +-- grant all (id) on mytable to myrole; +-- revoke all on mytable from myrole; +-- +-- Then the grant on the `id` column is revoked. +-- +-- This is unlike how grants for schemas and tables interact, where you need +-- privileges for BOTH the schema the table is in AND the table itself in order +-- to access the table. + +select (x.attrelid || '.' || x.attnum) as column_id, + nc.nspname as relation_schema, + x.relname as relation_name, + x.attname as column_name, + coalesce( + jsonb_agg( + jsonb_build_object( + 'grantor', u_grantor.rolname, + 'grantee', grantee.rolname, + 'privilege_type', x.prtype, + 'is_grantable', x.grantable + ) + ), + '[]' + ) as privileges +from + (select pr_c.grantor, + pr_c.grantee, + a.attrelid, + a.attnum, + a.attname, + pr_c.relname, + pr_c.relnamespace, + pr_c.prtype, + pr_c.grantable, + pr_c.relowner + from + (select pg_class.oid, + pg_class.relname, + pg_class.relnamespace, + pg_class.relowner, + (aclexplode(coalesce(pg_class.relacl, acldefault('r', pg_class.relowner)))).grantor as grantor, + (aclexplode(coalesce(pg_class.relacl, acldefault('r', pg_class.relowner)))).grantee as grantee, + (aclexplode(coalesce(pg_class.relacl, acldefault('r', pg_class.relowner)))).privilege_type as privilege_type, + (aclexplode(coalesce(pg_class.relacl, acldefault('r', pg_class.relowner)))).is_grantable as is_grantable + from pg_class + where (pg_class.relkind = any (array['r', + 'v', + 'm', + 'f', + 'p'])) ) pr_c(oid, relname, relnamespace, relowner, grantor, grantee, prtype, grantable), + pg_attribute a + where ((a.attrelid = pr_c.oid) + and (a.attnum > 0) + and (not a.attisdropped)) + union select pr_a.grantor, + pr_a.grantee, + pr_a.attrelid, + pr_a.attnum, + pr_a.attname, + c.relname, + c.relnamespace, + pr_a.prtype, + pr_a.grantable, + c.relowner + from + (select a.attrelid, + a.attnum, + a.attname, + (aclexplode(coalesce(a.attacl, acldefault('c', cc.relowner)))).grantor as grantor, + (aclexplode(coalesce(a.attacl, acldefault('c', cc.relowner)))).grantee as grantee, + (aclexplode(coalesce(a.attacl, acldefault('c', cc.relowner)))).privilege_type as privilege_type, + (aclexplode(coalesce(a.attacl, acldefault('c', cc.relowner)))).is_grantable as is_grantable + from (pg_attribute a + join pg_class cc on ((a.attrelid = cc.oid))) + where ((a.attnum > 0) + and (not a.attisdropped))) pr_a(attrelid, attnum, attname, grantor, grantee, prtype, grantable), + pg_class c + where ((pr_a.attrelid = c.oid) + and (c.relkind = any (ARRAY['r', + 'v', + 'm', + 'f', + 'p'])))) x, + pg_namespace nc, + pg_authid u_grantor, + (select pg_authid.oid, + pg_authid.rolname + from pg_authid + union all select (0)::oid as oid, + 'PUBLIC') grantee(oid, rolname) +where ((x.relnamespace = nc.oid) + and (x.grantee = grantee.oid) + and (x.grantor = u_grantor.oid) + and (x.prtype = any (ARRAY['INSERT', + 'SELECT', + 'UPDATE', + 'REFERENCES'])) + and (pg_has_role(u_grantor.oid, 'USAGE') + or pg_has_role(grantee.oid, 'USAGE') + or (grantee.rolname = 'PUBLIC'))) +group by column_id, + nc.nspname, + x.relname, + x.attname diff --git a/src/lib/sql/index.ts b/src/lib/sql/index.ts index 62a13ed9..f2ebcafd 100644 --- a/src/lib/sql/index.ts +++ b/src/lib/sql/index.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) +export const columnPrivilegesSql = await readFile(join(__dirname, 'column_privileges.sql'), 'utf-8') export const columnsSql = await readFile(join(__dirname, 'columns.sql'), 'utf-8') export const configSql = await readFile(join(__dirname, 'config.sql'), 'utf-8') export const extensionsSql = await readFile(join(__dirname, 'extensions.sql'), 'utf-8') @@ -20,8 +21,8 @@ export const tableRelationshipsSql = await readFile( ) export const rolesSql = await readFile(join(__dirname, 'roles.sql'), 'utf-8') export const schemasSql = await readFile(join(__dirname, 'schemas.sql'), 'utf-8') -export const tablesSql = await readFile(join(__dirname, 'tables.sql'), 'utf-8') export const tablePrivilegesSql = await readFile(join(__dirname, 'table_privileges.sql'), 'utf-8') +export const tablesSql = await readFile(join(__dirname, 'tables.sql'), 'utf-8') export const triggersSql = await readFile(join(__dirname, 'triggers.sql'), 'utf-8') export const typesSql = await readFile(join(__dirname, 'types.sql'), 'utf-8') export const versionSql = await readFile(join(__dirname, 'version.sql'), 'utf-8') diff --git a/src/lib/types.ts b/src/lib/types.ts index 5167edb1..06ca993e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -467,3 +467,51 @@ export const postgresTablePrivilegesRevokeSchema = Type.Object({ ]), }) export type PostgresTablePrivilegesRevoke = Static + +export const postgresColumnPrivilegesSchema = Type.Object({ + column_id: Type.RegEx(/^(\d+)\.(\d+)$/), + relation_schema: Type.String(), + relation_name: Type.String(), + column_name: Type.String(), + privileges: Type.Array( + Type.Object({ + grantor: Type.String(), + grantee: Type.String(), + privilege_type: Type.Union([ + Type.Literal('SELECT'), + Type.Literal('INSERT'), + Type.Literal('UPDATE'), + Type.Literal('REFERENCES'), + ]), + is_grantable: Type.Boolean(), + }) + ), +}) +export type PostgresColumnPrivileges = Static + +export const postgresColumnPrivilegesGrantSchema = Type.Object({ + column_id: Type.RegEx(/^(\d+)\.(\d+)$/), + grantee: Type.String(), + privilege_type: Type.Union([ + Type.Literal('ALL'), + Type.Literal('SELECT'), + Type.Literal('INSERT'), + Type.Literal('UPDATE'), + Type.Literal('REFERENCES'), + ]), + is_grantable: Type.Optional(Type.Boolean()), +}) +export type PostgresColumnPrivilegesGrant = Static + +export const postgresColumnPrivilegesRevokeSchema = Type.Object({ + column_id: Type.RegEx(/^(\d+)\.(\d+)$/), + grantee: Type.String(), + privilege_type: Type.Union([ + Type.Literal('ALL'), + Type.Literal('SELECT'), + Type.Literal('INSERT'), + Type.Literal('UPDATE'), + Type.Literal('REFERENCES'), + ]), +}) +export type PostgresColumnPrivilegesRevoke = Static diff --git a/src/server/routes/column-privileges.ts b/src/server/routes/column-privileges.ts new file mode 100644 index 00000000..ddde2c57 --- /dev/null +++ b/src/server/routes/column-privileges.ts @@ -0,0 +1,131 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import { PostgresMeta } from '../../lib/index.js' +import { + postgresColumnPrivilegesGrantSchema, + postgresColumnPrivilegesRevokeSchema, + postgresColumnPrivilegesSchema, +} from '../../lib/types.js' +import { DEFAULT_POOL_CONFIG } from '../constants.js' +import { extractRequestForLogging, translateErrorToResponseCode } from '../utils.js' + +const route: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.get( + '/', + { + schema: { + headers: Type.Object({ + pg: Type.String(), + }), + querystring: Type.Object({ + include_system_schemas: Type.Optional(Type.Boolean()), + // Note: this only supports comma separated values (e.g., "...?included_schemas=public,core") + included_schemas: Type.Optional(Type.String()), + excluded_schemas: Type.Optional(Type.String()), + limit: Type.Optional(Type.Integer()), + offset: Type.Optional(Type.Integer()), + }), + response: { + 200: Type.Array(postgresColumnPrivilegesSchema), + 500: Type.Object({ + error: Type.String(), + }), + }, + }, + }, + async (request, reply) => { + const connectionString = request.headers.pg + const includeSystemSchemas = request.query.include_system_schemas + const includedSchemas = request.query.included_schemas?.split(',') + const excludedSchemas = request.query.excluded_schemas?.split(',') + const limit = request.query.limit + const offset = request.query.offset + + const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) + const { data, error } = await pgMeta.columnPrivileges.list({ + includeSystemSchemas, + includedSchemas, + excludedSchemas, + limit, + offset, + }) + await pgMeta.end() + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(translateErrorToResponseCode(error, 500)) + return { error: error.message } + } + + return data + } + ) + + fastify.post( + '/', + { + schema: { + headers: Type.Object({ + pg: Type.String(), + }), + body: Type.Array(postgresColumnPrivilegesGrantSchema), + response: { + 200: Type.Array(postgresColumnPrivilegesSchema), + 400: Type.Object({ + error: Type.String(), + }), + }, + }, + }, + async (request, reply) => { + const connectionString = request.headers.pg + + const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) + const { data, error } = await pgMeta.columnPrivileges.grant(request.body) + await pgMeta.end() + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(400) + return { error: error.message } + } + + return data + } + ) + + fastify.delete( + '/', + { + schema: { + headers: Type.Object({ + pg: Type.String(), + }), + body: Type.Array(postgresColumnPrivilegesRevokeSchema), + response: { + 200: Type.Array(postgresColumnPrivilegesSchema), + 400: Type.Object({ + error: Type.String(), + }), + 404: Type.Object({ + error: Type.String(), + }), + }, + }, + }, + async (request, reply) => { + const connectionString = request.headers.pg + + const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString }) + const { data, error } = await pgMeta.columnPrivileges.revoke(request.body) + await pgMeta.end() + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(400) + if (error.message.startsWith('Cannot find')) reply.code(404) + return { error: error.message } + } + + return data + } + ) +} +export default route diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index d95903ca..bc611a36 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,5 +1,6 @@ import CryptoJS from 'crypto-js' import { FastifyInstance } from 'fastify' +import ColumnPrivilegesRoute from './column-privileges.js' import ColumnRoute from './columns.js' import ConfigRoute from './config.js' import ExtensionsRoute from './extensions.js' @@ -42,6 +43,7 @@ export default async (fastify: FastifyInstance) => { done() }) + fastify.register(ColumnPrivilegesRoute, { prefix: '/column-privileges' }) fastify.register(ColumnRoute, { prefix: '/columns' }) fastify.register(ConfigRoute, { prefix: '/config' }) fastify.register(ExtensionsRoute, { prefix: '/extensions' }) diff --git a/test/index.test.ts b/test/index.test.ts index e14feeef..63117d74 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,6 +16,7 @@ import './lib/publications' import './lib/triggers' import './lib/views' import './lib/foreign-tables' +import './server/column-privileges' import './server/materialized-views' import './server/table-privileges' import './server/typegen' diff --git a/test/server/column-privileges.ts b/test/server/column-privileges.ts new file mode 100644 index 00000000..d5472e61 --- /dev/null +++ b/test/server/column-privileges.ts @@ -0,0 +1,208 @@ +import { PostgresColumnPrivileges } from '../../src/lib/types' +import { app } from './utils' + +test('list column privileges', async () => { + const res = await app.inject({ method: 'GET', path: '/column-privileges' }) + expect( + res + .json() + .find( + ({ relation_schema, relation_name, column_name }) => + relation_schema === 'public' && relation_name === 'todos' && column_name === 'id' + ) + ).toMatchInlineSnapshot( + { column_id: expect.stringMatching(/^\d+\.\d+$/) }, + ` + { + "column_id": StringMatching /\\^\\\\d\\+\\\\\\.\\\\d\\+\\$/, + "column_name": "id", + "privileges": [ + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "INSERT", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "REFERENCES", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "SELECT", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "UPDATE", + }, + ], + "relation_name": "todos", + "relation_schema": "public", + } + ` + ) +}) + +test('revoke & grant column privileges', async () => { + let res = await app.inject({ method: 'GET', path: '/column-privileges' }) + const { column_id } = res + .json() + .find( + ({ relation_schema, relation_name, column_name }) => + relation_schema === 'public' && relation_name === 'todos' && column_name === 'id' + )! + + res = await app.inject({ + method: 'POST', + path: '/query', + payload: { + query: `create role r;`, + }, + }) + if (res.json().error) { + throw new Error(res.payload) + } + + res = await app.inject({ + method: 'POST', + path: '/column-privileges', + payload: [ + { + column_id, + grantee: 'r', + privilege_type: 'ALL', + }, + ], + }) + let privs = res.json() + expect(privs.length === 1) + expect(privs[0]).toMatchInlineSnapshot( + { column_id: expect.stringMatching(/^\d+\.\d+$/) }, + ` + { + "column_id": StringMatching /\\^\\\\d\\+\\\\\\.\\\\d\\+\\$/, + "column_name": "id", + "privileges": [ + { + "grantee": "r", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "UPDATE", + }, + { + "grantee": "r", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "SELECT", + }, + { + "grantee": "r", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "REFERENCES", + }, + { + "grantee": "r", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "INSERT", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "UPDATE", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "SELECT", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "REFERENCES", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "INSERT", + }, + ], + "relation_name": "todos", + "relation_schema": "public", + } + ` + ) + + res = await app.inject({ + method: 'DELETE', + path: '/column-privileges', + payload: [ + { + column_id, + grantee: 'r', + privilege_type: 'ALL', + }, + ], + }) + privs = res.json() + expect(privs.length === 1) + expect(privs[0]).toMatchInlineSnapshot( + { column_id: expect.stringMatching(/^\d+\.\d+$/) }, + ` + { + "column_id": StringMatching /\\^\\\\d\\+\\\\\\.\\\\d\\+\\$/, + "column_name": "id", + "privileges": [ + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "UPDATE", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "SELECT", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "REFERENCES", + }, + { + "grantee": "postgres", + "grantor": "postgres", + "is_grantable": false, + "privilege_type": "INSERT", + }, + ], + "relation_name": "todos", + "relation_schema": "public", + } + ` + ) + + res = await app.inject({ + method: 'POST', + path: '/query', + payload: { + query: `drop role r;`, + }, + }) + if (res.json().error) { + throw new Error(res.payload) + } +})