Hands-On with PostgreSQL Authorization – Part 2 – Row-Level Security
tangramvision.com
tangramvision.com
It was eye opening for me. In every traditional codebase I worked on, this is usually handled is such a slow and messy way, adding another layer of filtering on top of already slow and complex queries. This is always one of the first things that needs to be cached in Redis. Instead, row-level security solves the problem in a very elegant, simple and performant way in my opinion.
Obviously it works better when all your logic is already at the DB level (e.g. PostgREST). I wouldn't imagine using DB roles and row-level security in a traditional backend where all the logic is at the application level (e.g. Django, Rails…). Edit: seems like there are workarounds to use RLS with Django [3].
[2] https://postgrest.org/en/stable/auth.html#roles-for-each-web...
[3] https://pganalyze.com/blog/postgres-row-level-security-djang...
`RESET ROLE; SET ROLE app_username;` could be done for each query / transaction / when fetching the connection from the pool.
Also, it’s more convenient to use SET LOCAL ROLE <ROLE_NAME>, since that only keeps the role for the transaction. Manually resetting it is error prone (IME), and forgetting will have the supposedly “temporary” role bleed to the next transaction.
Whilst this is a very good approach when all your data is stored in a single datastore, as applications grow it is common to start breaking out into more optimised data stores eg you may have few relational databases, a fast lookup source and a search index. This presents a problem of enforcing authorization down into each system.
An alternate way to tackle this is to have the authorization system produce the conditions which need to be applied dynamically at request time (with all the relevant context) which can then be pushed down to each fetching layer as needed [2][3]. This gives far more flexibility in the sorts of authorization rules which can be applied to the data and doesn't tie it to a single bit of technology.
As a real world example we have an integration with Prisma[3] which maps a query plan into a Prisma query format dynamically based on the context of the user and the currently live policies[4].
[0]: https://cerbos.dev
[1]: https://cerbos.dev/blog/filtering-data-using-authorization-l...
[2]: https://docs.cerbos.dev/cerbos/latest/api/index.html#resourc...
[3]: https://prisma.io/
I would never guess people would use row level security for this for the reasons you've outlined, rarely (?) is one database the only resource you need to authorise access to, so you will need an authz for all non-db things anyway. Always assumed row-level authz was more for data warehouse type applications where a User has a client directly connected to a database, not intermediated thru multiple levels of abstraction.
Cerbos approach logically seems to make more sense to me than the general Zanzibar inspired methods like Authzed and others. I could never wrap my head around how they could authorise access (Pre and Post filtering?) to data without pushing down conditions to join with into the store. Actually having a 3rd party system like Cerbos be able to push down conditions and have good ergonomics is another thing, that is a tough problem.
One piece of feedback I have -- I wasn't entirely sure what I was looking at from the homepage, there's a lot going on messaging and content-wise and I had to watch the video to get it.
Maybe something more to the point like "Takes policies, converts them to adapter-specific filter conditions that you tack on to your queries" might be helpful
Leveraging most RDBMS security features seem to be geared for an ever shrinking set of use cases where a mostly static set of users are given direct access to a SQL prompt, or a simple record to GUI application interface.
It always depends on the domain. If the data model for the app is simple enough, RLS can take you pretty far. Enterprise apps that require you to support the various vague interpretations of "RBAC" or domains that have more complex data models will eventually need some kind of more sophisticated authorization solution. There are a variety solutions at that point (e.g. SpiceDB[1], oso[2], OPA[3]) and you'll be making your decision based on not only the implementation of the technology, but concerns that have cropped in your business requirements:
- "How will additional microservices check permissions?"
- "How can we test and enforce that our authorization system is correct?"
- "Can I support user-defined permissions?"
[0]: https://authzed.com
> "How will additional microservices check permissions?"
SpiceDB is a database optimized for resolving subjects' access to resources. Being a database, it suggests storing the canonical authorization data within it and performing queries to it from various microservices. This is the strategy employed by most hyper-scalers and but also companies that have heavily invested in in-house authorization like like Airbnb and Carta.
> "How can we test and enforce that our authorization system is correct?"
SpiceDB has developers write schemas, but unlike other databases, it has tooling that can check assertions and audit all possible access. This tooling can be shared/explored via the Authzed Playground[1] or added to your CI/CD pipeline with GitHub Actions[2]
> "Can I support user-defined permissions?"
There are various ways to accomplish this with SpiceDB. User behavior can be used to pragmatically generate schemas or you can write very abstract schemas that push designs that are typically enforced at schema-validation/compile-time (think DDL) to runtime (think DML).
begin;
set local role myrole; -- the important part
SELECT * FROM page;
commit;
SET LOCAL ROLE webuser (used by all transactions that come from the web application)
SET LOCAL "request.web.sub" = '<internal application's primary key for this specific user'
Then I can in queries check for the current role (where by 'role' I mean my application's user/account/role set via "request.web.sub", not a postgres role) via: create or replace function auth.fn_requesting_role()
RETURNS uuid LANGUAGE sql AS
$func$
with crole as (
select coalesce(
nullif(current_setting('request.web.sub', true), ''),
nullif(current_setting('request.jwt.sub', true), '')
)::uuid as role_id
)
select crole.role_id::uuid from crole
join auth.role on role.role_id = crole.role_id::uuid;
$func$;
You can then find out the current requesting user/account/role ID in RLS policies and other functions, and apply whatever permissions you like there.The reason I have 'request.jwt.sub' is just for future if I want to allow requests to come from PostgREST as well and use the same authorisation checks.
EXECUTE 'SET ROLE ' || (SELECT rolname FROM pg_roles WHERE oid = 17026)
But I wouldn't use those oid's because you are leaking some implementation detail, it's probably best to just stick with the actual role names instead of a reference.
Regarding database connections -- one way to avoid needing a connection per user is to use something like PostgREST[2] to handle incoming requests, identify the user making the request, and use an existing db pool connection to switch roles and execute whatever queries are requested. EDIT: RedShift1 beat me to this explanation by a little bit! :)
RLS certainly isn't the answer for every domain or problem size, but I've been surprised by how powerful it is compared with how relatively unknown it is.
[1]: https://supabase.com/blog/2021/03/31/supabase-cli#migrations
You need to use
``` SET my.user = 'user1'; SELECT * FROM todos; ```
And in you RLS you can then use
``` CREATE POLICY owner ON todos USING (user = current_setting('my.user')); ```
I thought it was interesting because it was a change from the usual authentication cycle of storing some session information and handling all the authentication through sessions and restricted queries.
Although, I would say that this merely shows what is possible with the database level security. It might be useful for an internal db with less complicated permission system.
Authorisation Libraries on application level are more scalable and more maintainable than this database level security. Also, just by reading the application code you can tell the expected behaviour...
Plus, I don’t see how “just by reading application code you can tell the expected behavior” doesn’t apply to RLS. Policies are written in a consistent format. USING for visibility, WITH CHECK for altering. I only have to keep an eye for these, and I’ll already get a good summary on what it does, no?