Este texto es una traducción y mezcla de estos temas hecha directamente del manual.
PostgREST está diseñado para mantener la DB en el centro de la seguridad de la API. Toda la autorización ocurre en la base de datos. Autentificar al usuario, es decir, verificar que el cliente es quién dice ser para posteriormente autorizar sus acciones, es trabajo de PostgREST.
El administrador de la base de datos creará y configurará en PostgREST tres roles:
- authenticator es usado para conectar PostgREST con la basse de datos y debe ser configurado con derechos muy restringidos. Su función es cambiar de role cuando el usuario ya ha sido autentificado.
- anonymous es un rol para usuarios que o bien no han podido ser autenticados o bien no necesitan hacerlo para usar algunos servicios.
- webusers son usuarios "normales" de la API
Dado un token JWT, si tiene éxito la autentificación el role cambiará automáticamente al que venga en el token. En caso contrario se cambiará al rol anonymous. La operación se realiza usando el comando SET ROLE
.
La ventaja de usar los tokens es que se pueden mantener la comunicaciones sin estado y, además, ahorrar búsquedas en la base de datos para las verificaciones. De todos los claims (campos) que puede llevar un token, PostgREST sólo se interesa por uno llamado role, tal como:
{
"role": "user123"
}
Cuando PostgREST se encuentra este token, lo primero que hace es cambiar de role durante toda la duración de la consulta http mediante:
SET LOCAL ROLE user123;
Notar que el administrador de la base de datos debe permitir previamente al rol authenticator cambiar al del usuario mediante
GRANT user123 TO authenticator;
-- similarly for the anonymous role
-- GRANT anonymous TO authenticator;
Y debe hacer lo mismo dando permisos de cambio a anonymous. El administrador de la base de datos debe configurar el usuario anónimo de forma que no pueda acceder a cosas que no pueda.
Los tokens pueden generarse desde la base de datos o por un servicio externo.
Para hacer una llamada autentificada el cliente debe incluir una cabecera Authorization con el valor Bearer . Por ejemplo:
curl "http://localhost:3000/foo" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZSIsImV4cCI6MTQ3NTUxNjI1MH0.GYDZV3yM0gqvuEtJmfpplLBXSGYnke_Pvnl0tbKAjB4"
La autorización es el proceso por el que se garantizan y verifican los permisos de acceso a la base de datos.
Postgres maneja los permisos usando el concepto de roles.
Podemos dar un rol a un solo usuario de la base de datos o a un grupo de usuarios, dependiendo de cómo lo configuremos. Por tanto, tenemos tres posibles formas de configurar los permisos de los usuarios:
- Crear un rol para cada usuario web
- Usuarios web que comparten un rol de DB
- Modelo híbridos usuario-rol (tienen los dos anteriores)
Si queremos que cada usuario web tenga su porpio rol en la db. Esto es especialmente cómodo con usando tokens JWT ya que:
- El propio token transporta el rol del usuario y Postgrest pasa automáticamente a ese rol antes de hacer nada. Por lo tanto, el current_user queda seteado a ese rol, identificando directamente al usuario en toda la vida de la transacción.
- Podemos restringir muy facilmente la visibilidad y el acceso del current_user. Ver el siguiente ejemplo.
Tenemos un sistema de chat y queremos que un usuario pueda ver sólo los mensajes que ha enviado o que ha recibido.
CREATE TABLE chat (
message_uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
message_time TIMESTAMP NOT NULL DEFAULT now(),
message_from NAME NOT NULL DEFAULT current_user,
message_to NAME NOT NULL,
message_subject VARCHAR(64) NOT NULL,
message_body TEXT
);
ALTER TABLE chat ENABLE ROW LEVEL SECURITY;
Vemos que el usuario que recibe o envia se codifica en los campos message_from y message_to. También vemos que hemos activado la seguridad a nivel de fila, así que creamos la siguiente policy.
CREATE POLICY chat_policy ON chat
USING ((message_to = current_user) OR (message_from = current_user))
WITH CHECK (message_from = current_user)
Esta sentencia hace que cualquier acción sobre la tabla pase necesariamente por los mensajes recidos o enviados por el current_user. Además, el check hace que no sea posible cambiar el valor de message_from para poner otro usuario.
Recordar que los roles van a nivel del cluster de DB.
Podemos hacer que muchos usuario web compartan un solo rol. Esto podria ser adecuado en entornos con muchos usuarios que incluso pueden darse de alta ellos mismos. Por ello, podemos hacer que todos los usuarios que hayan hecho sign-in tengan el rol webuser.
Como Postgrest setea el claim del jwt en request.jwt.claims por transacción, para distinguir entre unos usuarios y otros podemos extraer el rol del claim de la siguiente forma:
current_setting('request.jwt.claims', true)::json->>'email';
El true hace que se devuelva NULL si el claim no está seteado.
Podemos tener policies de rol y heredar a tipos de usuario genéricos desde estos.
CREATE ROLE webuser NOLOGIN;
-- grant this role access to certain tables etc
CREATE ROLE user000 NOLOGIN;
GRANT webuser TO user000;
-- now user000 can do whatever webuser can
GRANT user000 TO authenticator;
-- allow authenticator to switch into user000 role
-- (the role itself has nologin)
Se pueden dar permisos explícitos para dar acceso a roles sobre todo un esquema.
GRANT USAGE ON SCHEMA api TO webuser;
No se recomienda dar acceso directo a las tablas. Es mejor exponerlas a través de views.
De todas formas, siempre podemos hacerlo de esta forma:
GRANT
SELECT
, INSERT
, UPDATE(message_body)
, DELETE
ON chat TO webuser;
En este caso, sólo damos permiso de update a webuser sobre el campo message_body.
Por defecto, cualquier función que creemos tiene permisos abiertos a PUBLIC para su acceso.
Para evitar esto, podemos revocar ese permiso de forma general con la siguiente sentencia:
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
A partir de este momento, debemos dar permisos explícitos a los roles para ejecutar funciones. Sea a funciones concretas:
GRANT EXECUTE ON FUNCTION login TO anonymous;
GRANT EXECUTE ON FUNCTION signup TO anonymous;
sea a todas las funciones de un esquema. Por ejemplo, a un usuario autentificado con un rol:
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA api TO web_user;
Cuando se dan permisos de ejecución de una función a un rol, ese rol también debería tener permisos sobre las tablas que se usan dentro de la función. Esto puede ser difícil de seguir y de mantener, así que una alternava es usar security definer al final de de la función.
Ejemplo:
-- login as a user wich has privileges on the private schemas
-- create a sample function
create or replace function login(email text, pass text, out token text) as $$
begin
-- access to a private schema called 'auth'
select auth.user_role(email, pass) into _role;
-- other operations
-- ...
end;
$$ language plpgsql security definer;
Esta función tiene acceso a auth.user_role aunque el usuario actual no lo tenga.
Las vistas se crean con los privigelios del propietario (owner). Por lo tanto, cuando las creamos usando un SUPERUSER, todas las policies se bypasean. Si no queremos este comportamiento, podemos usar security_invoker de esta forma:
CREATE VIEW sample_view WITH (security_invoker = true) AS
SELECT * FROM sample_table;
Vamos a crear las tablas y procedimientos necesarios para gestionar los usuarios sin necesidad de ningún servicio externo.
Lo primero es crear un esquema llamado basic_atuh. Nunca debemos exponer un esquema publicamente, es mejor usar un esquema diferente para ello y referenciar internamente la información.
Creamos una tabla para los usuarios.
-- We put things inside the basic_auth schema to hide
-- them from public view. Certain public procs/views will
-- refer to helpers and tables inside.
create table
basic_auth.users (
email text primary key check ( email ~* '^.+@.+\..+$' ),
pass text not null check (length(pass) < 512),
role name not null check (length(role) < 512)
);
Como pg no permite referenciar role a pg_roles, creamos un trigger para hacerlo manualmente.
create function
basic_auth.check_role_exists() returns trigger as $$
begin
if not exists (select 1 from pg_roles as r where r.rolname = new.role) then
raise foreign_key_violation using message =
'unknown database role: ' || new.role;
return null;
end if;
return new;
end
$$ language plpgsql;
create constraint trigger ensure_user_role_exists
after insert or update on basic_auth.users
for each row
execute procedure basic_auth.check_role_exists();
Ahora necesitaremos la extensión pgcrypto para guardar los passwords en la DB.
create extension pgcrypto;
create function
basic_auth.encrypt_pass() returns trigger as $$
begin
if tg_op = 'INSERT' or new.pass <> old.pass then
new.pass = crypt(new.pass, gen_salt('bf'));
end if;
return new;
end
$$ language plpgsql;
create trigger encrypt_pass
before insert or update on basic_auth.users
for each row
execute procedure basic_auth.encrypt_pass();
Creamos una función que devuelve un role si el usuario y el password son correctos.
-- devuleve el rol de un usaurio si las credenciales son correctas
create function
basic_auth.user_role(email text, pass text) returns name
language plpgsql
as $$
begin
return (
select role from basic_auth.users
where users.email = user_role.email
and users.pass = crypt(user_role.pass, users.pass)
);
end;
$$;
Tal y como vimos anteriormente, debemos crear dos roles para el sistema de autenticación: anonymous y authenticator.
create role anon noinherit;
create role authenticator noinherit;
grant anon to authenticator;
En el fichero de configuración de añadiremos
db-anon-role = "anon"
Podemos hacerlo instalando la extensión pgjwt.
Creamos una función para probarlo
CREATE FUNCTION jwt_test(OUT token text) AS $$
SELECT public.sign(
row_to_json(r), 'reallyreallyreallyreallyverysafe'
) AS token
FROM (
SELECT
'my_role'::text as role,
extract(epoch from now())::integer + 300 AS exp
) r;
$$ LANGUAGE sql;
Ahora ya la tenemos accesible en /rpc/jwt_test
.
Para evitar poner hard coded el secret, la podemos grabar como propiedad de la base de datos y recuperarla posteriormente.
-- run this once
ALTER DATABASE mydb SET "app.jwt_secret" TO 'reallyreallyreallyreallyverysafe';
-- then all functions can refer to app.jwt_secret
SELECT sign(
row_to_json(r), current_setting('app.jwt_secret')
) AS token
FROM ...
Para hacer el login, creamos una función con el security definer, es decir que el rol que ejecuta la función también se le da permiso expreso para que use todos los recursos que usa la función para ejecutarse. Por lo tanto, damos permiso al usuario anon para ejecutar esta función para ver si puede logearse.
-- login should be on your exposed schema
create function
login(email text, pass text, out token text) as $$
declare
_role name;
begin
-- check email and password
select basic_auth.user_role(email, pass) into _role;
if _role is null then
raise invalid_password using message = 'invalid user or password';
end if;
select sign(
row_to_json(r), 'reallyreallyreallyreallyverysafe'
) as token
from (
select _role as role, login.email as email,
extract(epoch from now())::integer + 60*60 as exp
) r
into token;
end;
$$ language plpgsql security definer;
grant execute on function login(text,text) to anon;
Ahora ya podemos usar esta función que nos devolverá un jwt, usando un usuario y password.
curl "http://localhost:3000/rpc/login" \
-X POST -H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "pass": "foobar" }'
Que nos devolverá el token que debemos incluir en la cabecera en todas la llamadas posteriores que hagamos a PostgREST.