docs: Supbase RBAC with EntraID - initial version
All checks were successful
Deploy pages / deploy (push) Successful in 1m33s
All checks were successful
Deploy pages / deploy (push) Successful in 1m33s
This commit is contained in:
parent
9e3d224ab1
commit
d9118c879b
3 changed files with 261 additions and 0 deletions
BIN
content/images/2024-12-supabase-rbac-entraid/app-roles.png
Normal file
BIN
content/images/2024-12-supabase-rbac-entraid/app-roles.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 105 KiB |
BIN
content/images/2024-12-supabase-rbac-entraid/assign-roles.png
Normal file
BIN
content/images/2024-12-supabase-rbac-entraid/assign-roles.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 101 KiB |
261
content/post/2024-12-supabase-rbac-entraid.md
Normal file
261
content/post/2024-12-supabase-rbac-entraid.md
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
+++
|
||||||
|
author = "Peter Kurfer"
|
||||||
|
title = "RBAC in Supabase with EntraID"
|
||||||
|
description = "How to use EntraID defined roles in Supabase for RLS policies"
|
||||||
|
date = "2024-12-03"
|
||||||
|
tags = [
|
||||||
|
"supabase",
|
||||||
|
"entraid",
|
||||||
|
"RBAC",
|
||||||
|
"RLS"
|
||||||
|
]
|
||||||
|
+++
|
||||||
|
|
||||||
|
[Supabase](https://supabase.com/) is an awesome tool for building (CRUD) applications in no time.
|
||||||
|
For those who are not familiar with Supabase, it is an open-source Firebase alternative with a focus on privacy and security.
|
||||||
|
Compared to other tools, Supabase is focused on the Postgres database and provides a lot of features out of the box, like authentication, authorization, and real-time subscriptions.
|
||||||
|
Also their tech stack appeals to me, as they are using Go, Rust, Elixir and (sadly) Node.js for all their services.[^1]
|
||||||
|
|
||||||
|
When building applications - especially in a business context - there's no way around authentication and authorization.
|
||||||
|
A very common approach to authorization is Role-Based Access Control (RBAC).
|
||||||
|
In RBAC, permissions are assigned to roles, and roles are assigned to users and/or groups.
|
||||||
|
|
||||||
|
In this post, I want to show you how to use roles defined in [EntraID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) in Supabase for Row-Level Security (RLS) policies.
|
||||||
|
When looking into the [Supabase documentation](https://supabase.com/docs/guides/auth/social-login/auth-azure) you will notice that they mostly focus on how to integrate Azure EntraID and how to implement authentication in your frontend app.
|
||||||
|
When it comes to authorization, they provide [high level documentation](https://supabase.com/docs/guides/database/postgres/row-level-security) on how to use RLS policies in general with Supabase including examples for their helper functions and there's even an [article](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac) about custom claims and role-based access control (RBAC), but all their documentation assumes that you want to manage roles **within** Supabase instead of using what your external authentication provider gives you for free.
|
||||||
|
|
||||||
|
## Basic setup
|
||||||
|
|
||||||
|
As also described in the Supabase documentation, the first step is to ensure that you have a so called "App Registration" in your Azure subscription.
|
||||||
|
App Registrations are the entity in Azure that define OpenID Connect applications.
|
||||||
|
Also, application roles are **defined** in the App Registration - and **assigened** in the corresponding "Enterprise Application" - why that is, is a topic for another day.
|
||||||
|
Assuming, that you already followed the official docs for the basic authentication for Azure EntraID, you can now directly jump to the "App Roles" section in your App Registration and define your roles:
|
||||||
|
|
||||||
|
![App Roles](/images/2024-12-supabase-rbac-entraid/app-roles.png)
|
||||||
|
|
||||||
|
For this example, I defined the roles:
|
||||||
|
|
||||||
|
- `Admin`
|
||||||
|
- `Reader`
|
||||||
|
- `Writer`
|
||||||
|
|
||||||
|
but you can define as many roles as you like.
|
||||||
|
|
||||||
|
Now, as already mentioned, to **assign** roles, you have to head over to the "Enterprise Applications" section in your Azure subscription and assign the roles to the users or groups you want to have them:
|
||||||
|
|
||||||
|
![Assign Roles](/images/2024-12-supabase-rbac-entraid/assign-roles.png)
|
||||||
|
|
||||||
|
as you may have noticed, I assigned multiple roles to the same user, which results in the user having multiple roles in the token.
|
||||||
|
|
||||||
|
## Database setup
|
||||||
|
|
||||||
|
Now that we have our roles defined and assigned, we can start with the database setup.
|
||||||
|
Before implementing any policy, we first need some tables to work with.
|
||||||
|
|
||||||
|
To keep the scenario as simple as possible we'll start with two tables: `users` and `todos`:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
CREATE SCHEMA IF NOT EXISTS public;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.users (
|
||||||
|
"id" UUID PRIMARY KEY NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.todos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner UUID NOT NULL DEFAULT (auth.jwt() -> 'user_metadata' -> 'custom_claims' ->> 'tid')::uuid REFERENCES public.users (id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
completed BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.todos enable ROW level security;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** normally I'd put the `users` table in a separate schema e.g. `internal` to avoid that it's visible to the users through the API, but for this example we'll keep it simple. Alternatively we could limit the access to the `users` table with RLS policies but again, for the sake of this article we'll skip this part.
|
||||||
|
|
||||||
|
## Implementing RLS policies
|
||||||
|
|
||||||
|
With the tables in place, we can now start with the RLS policies.
|
||||||
|
We want to enforce the following business rules:
|
||||||
|
|
||||||
|
- `Admin` can read and write all todos
|
||||||
|
- `Reader` can read all todos
|
||||||
|
- `Writer` can create new todos and update their own todos
|
||||||
|
|
||||||
|
To be able to implement these rules, we need to be able to access the aforementioned roles from the token.
|
||||||
|
Supabase provides a helper function `auth.jwt()` to access the users JSON Web Token (JWT).
|
||||||
|
The token contains `user_metadata` that encapsulates the data coming from the authentication provider.
|
||||||
|
To play around with the token, you can open the SQL editor in the Supabase dashboard, change the role to some existing user (impersonate) and run the following query:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
SELECT auth.jwt()->'user_metadata' AS user_metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
This will return the `user_metadata` object from the token:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iss": "https://login.microsoftonline.com/<your EntraID tenant ID>/v2.0",
|
||||||
|
"sub": "<subject>",
|
||||||
|
"email": "<email>",
|
||||||
|
"provider_id": "<provider_id>",
|
||||||
|
"custom_claims": {
|
||||||
|
"tid": "<object id of your user>",
|
||||||
|
"email": "<email>",
|
||||||
|
"roles": ["Reader", "Writer"]
|
||||||
|
},
|
||||||
|
"email_verified": true,
|
||||||
|
"phone_verified": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You might already see where this is going.
|
||||||
|
We can access the roles by using
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
auth.jwt()->'user_metadata'->'custom_claims'->'roles'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
The `Admin` role should be able to read and write all todos.
|
||||||
|
To implement this, we need a set of policies:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
CREATE POLICY "Role Admin can read all todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Admin' );
|
||||||
|
```
|
||||||
|
|
||||||
|
There's already a lot going on here, so let's break it down:
|
||||||
|
|
||||||
|
- `CREATE POLICY` is used to create a new policy, the name can be anything you like
|
||||||
|
- every policy is only applicable to a single table, in this case `public.todos`
|
||||||
|
- `FOR SELECT` specifies the operation the policy is applied to, in this case `SELECT`, other options are: `INSERT`, `UPDATE`, `DELETE` or `ALL`
|
||||||
|
- `TO authenticated` specifies the role the policy is applied to, in this case `authenticated` which is the default role for authenticated users in Supabase
|
||||||
|
- `USING` specifies the condition that has to be met for selecting the row, in this case we check if the `Admin` role is present in the token
|
||||||
|
- `WITH CHECK` can be used to enforce additional conditions for `INSERT`, `UPDATE` or `DELETE` operations e.g. to ensure that the user can only create todos for itself
|
||||||
|
|
||||||
|
but we also want the `Admin` role to be able to write (`INSERT`/`UPDATE`/`DELETE`) todos:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
-- Admin can create todos - including on behalf of others
|
||||||
|
CREATE POLICY "Role Admin can create todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Admin' );
|
||||||
|
|
||||||
|
-- Admin can update todos of everyone
|
||||||
|
CREATE POLICY "Role Admin can write all todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Admin' );
|
||||||
|
|
||||||
|
-- Admin can delete todos of everyone
|
||||||
|
CREATE POLICY "Role Admin can delete todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Admin' );
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writers
|
||||||
|
|
||||||
|
The `Writer` role can create new todos and update their own todos.
|
||||||
|
As you might have guessed, we will also need more than one policy to implement this:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
CREATE POLICY "Role Writer can read its own todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Writer' AND (owner = (auth.jwt() -> 'user_metadata' -> 'custom_claims' ->> 'tid')::uuid) );
|
||||||
|
```
|
||||||
|
|
||||||
|
This policy is a bit more complex than the previous one.
|
||||||
|
Not only are we checking the `Writer` role, but we also check if the `owner` of the todo is the same as the `tid` from the token.
|
||||||
|
The `tid` is the user ID from the authentication provider - in this case the object ID of the user in Azure EntraID.
|
||||||
|
|
||||||
|
**Remark**: This is **not** the ID of the user within Supabase! Supabase has its own helper `auth.uid()` to access the user ID but I prefer to use the object ID of the user directly from the token because it avoids one additional layer of indirection. For this reason I also have an independent `users` table in the database where I store the object ID of the user e.g. with a trigger on the `INSERT` of a new user.
|
||||||
|
|
||||||
|
For the `Writer` role to be able to create new todos, we need the following policy:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
-- Writer can create todos - owner is set to the tid of the user, if provided it will be checked whether the user is the owner
|
||||||
|
CREATE POLICY "Role Writer can create todos for itself"
|
||||||
|
ON public.todos
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Writer' AND (owner = (auth.jwt() -> 'user_metadata' -> 'custom_claims' ->> 'tid')::uuid) );
|
||||||
|
|
||||||
|
-- Writer can delete its own todos
|
||||||
|
CREATE POLICY "Role Writer can delete todos for itself"
|
||||||
|
ON public.todos
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Writer' AND (owner = (auth.jwt() -> 'user_metadata' -> 'custom_claims' ->> 'tid')::uuid) );
|
||||||
|
```
|
||||||
|
|
||||||
|
### Readers
|
||||||
|
|
||||||
|
The `Reader` role can read all todos:
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
CREATE POLICY "Role Reader can read all todos"
|
||||||
|
ON public.todos
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING ( auth.jwt()->'user_metadata'->'custom_claims'->'roles' ? 'Reader' );
|
||||||
|
```
|
||||||
|
|
||||||
|
As this is basically the same policy as the `Admin` can read all, we'll not further discuss this policy.
|
||||||
|
|
||||||
|
## Final thoughts
|
||||||
|
|
||||||
|
### RLS
|
||||||
|
|
||||||
|
PostgreSQL has a lot to offer when it comes to RLS policies, so make sure to check out the [official documentation](https://www.postgresql.org/docs/current/sql-createpolicy.html) for more information.
|
||||||
|
|
||||||
|
### EntraID
|
||||||
|
|
||||||
|
In real-world applications I'd recommend to create a `entraid` schema and store some auxiliary functions there similar to the ones provided by Supabase in the `auth` scheme.
|
||||||
|
|
||||||
|
```postgresql
|
||||||
|
CREATE SCHEMA IF NOT EXISTS entraid;
|
||||||
|
|
||||||
|
-- grant usage on the schema to authenticated users
|
||||||
|
GRANT usage ON schema entraid TO authenticated;
|
||||||
|
|
||||||
|
-- helper to return the object ID of the current user
|
||||||
|
CREATE OR REPLACE function "entraid"."uid" () returns uuid
|
||||||
|
SET
|
||||||
|
search_path = '' AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN (auth.jwt() -> 'user_metadata' -> 'custom_claims' ->> 'tid')::uuid;
|
||||||
|
END;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
-- grant execution permissions on the function to authenticated users
|
||||||
|
GRANT EXECUTE ON function "entraid"."uid" TO authenticated;
|
||||||
|
|
||||||
|
-- helper to return the roles defined in EntraID of the current user
|
||||||
|
CREATE OR REPLACE function "entraid"."roles" () returns jsonb
|
||||||
|
SET
|
||||||
|
search_path = '' AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN auth.jwt() -> 'user_metadata' -> 'custom_claims' -> 'roles';
|
||||||
|
END;
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
-- grant execution permissions on the function to authenticated users
|
||||||
|
GRANT EXECUTE ON function "entraid"."roles" TO authenticated;
|
||||||
|
```
|
||||||
|
|
||||||
|
This would also make the policies more readable and maintainable.
|
||||||
|
|
||||||
|
[^1]: I'm not a big fan of Node.js, but there are reasons why it can make sense to open this rabbit hole. It's just a personal preference.
|
Loading…
Reference in a new issue