As a result of the new changes in Firebase Storage allowing you to read Firestore data, we suggest to use the new method outlined in the following article: Using Firestore in Firebase Storage Rules
Firebase Storage's security rules are relatively simple to write when the entity to protect is individual: for example, making sure that the current user can perform certain actions, or that an individual organization can access certain assets in a bucket.
What's slightly trickier with Firebase Storage is writing rules that affect several users or groups. What makes it particularly hard is the lack of context we can use in the security rules: for example, we cannot read any data from Firestore, and are confined to the session's authentication data of the signed-in user.
Among the authentication data, we can access within the Firebase Storage security rules, we also find custom claims, which are some metadata that you can attach to each user. This is great, but the bad news is that the space is extremely limited, hence we cannot simply store all the groups an individual belongs to.
For example, if your user belongs to an organization, you may want the user to be able to read all the assets belonging to the organization. Unfortunately, this is simpler said than done.
While the Firebase documentation does indeed mention a solution involving custom tokens for this specific problem, but it does not go as far as showing a proper implementation.
Thankfully, we at Makerkit have thought of a simple and painless solution, which we will describe in this blog post.
Scenario: A shared folder within an Organization
The following is an extremely common scenario in any SaaS that supports users of an organization, project, or team: sharing a folder among team members of the same team (or whatever we want to call it).
Writing rules that can guard against nonauthorized access to a specific organization would be fairly simple, but how can we write the logic necessary to protect access to the users that do not belong to the team?
-
One way would be to use Firebase Storage from the Firebase Admin on the server, and block access using server-side logic. Unfortunately, this is cumbersome and hard to maintain, which defeats the purpose of using Firebase Storage straight from the client. So, in most cases, this is not the way to go.
-
Another way can be using Custom Claims, and storing the group information (such as an ID) in the user's session data. Because we can access these in the Firebase Storage security rules, we can write them such to block or grant access to the user.
Let's assume our Organization has the ID 123, and the current user belongs to such organization. Now, let's try to write a security rule that knowing the user's custom claims would be able to grant access to the user:
So, let's write a rule that, given an organization organizationId
, can
grant access to only the users who have a custom claim
named
organizationId
that matches the path being written.
The path we want to write to is: /{organizationId}/uploads/{fileName=**}
:
match /{organizationId}/uploads/{fileName=**} {
allow create, read: if organizationId == request.auth.token.organizationId;
allow delete, update: if resource.metadata.organizationId == request.auth.token.organizationId;
}
This rule would prevent users who do not have the correct custom claim
organizationId
to rear, write, update or delete any item within that path.
The question now is, how can we assign the organizationId
custom claim to
the user?
We can do this when:
- the server renders a page
- the user changes organization
Let's see how we do it in Makerkit!
Assigning Custom Claims
On any guarded page (eg. server-side protected) of the application, we have the possibility to assign the current organization ID (passed via session cookie) to the user's metadata.
The logic is simple:
- fetch custom claims
- get the current organization ID from the cookies
- if these are different, then update the custom claims with the current organization ID
Assigning custom claims at page load
Assigning custom claims is also reasonably simple using the Firebase Authentication SDK.
1) First, we define the function to update the custom claims:
function setOrganizationIdCustomClaims(
userId: string,
organizationId: string,
existingClaims: UnknownObject
) {
const auth = getAuth();
return auth.setCustomUserClaims(userId, {
...existingClaims,
organizationId,
});
}
2) Here is a snippet of code with the logic above:
const userDidChangeOrganization =
authOrganizationId !== currentOrganizationId;
const shouldUpdateTokenClaims =
!authOrganizationId || userDidChangeOrganization;
if (shouldUpdateTokenClaims) {
await setOrganizationIdCustomClaims(
user.id,
organization.id,
customClaims
);
}
Where to add this logic?
Remember: the snippet above should be called before rendering the page,
ideally within the getServerSideProps
function.
Assigning custom claims when the user switches organization
If your users can switch to another organization, we need to make sure to update the metadata with the current organization ID.
Notice: instead of adding a new organization, we simply update it due to the limited amount of space we can store within the custom claims.
You can achieve this in two different ways:
- Simply refresh the page when the user changes organization (which sometimes can be good for avoiding state management issues)
- A more seamless way that involves sending an HTTP request to update the current user's custom claims
In Makerkit, we opted for the second option, which is a bit more complex but offers a better UX.
An API endpoint to update the user's custom claims
- First of all, we need an API endpoint capable of updating the user's custom claims using the newly selected organization:
import { NextApiRequest, NextApiResponse } from 'next';
import { getAuth } from 'firebase-admin/auth';
import { withAuthedUser } from '~/core/middleware/with-authed-user';
import { withPipe } from '~/core/middleware/with-pipe';
import { withMethodsGuard } from '~/core/middleware/with-methods-guard';
import { getCurrentOrganization } from '~/lib/server/organizations/get-current-organization';
import { forbiddenException, notFoundException } from '~/core/http-exceptions';
export async function organizationTokenHandler(
req: NextApiRequest,
res: NextApiResponse
) {
const userId = req.firebaseUser.uid;
const organizationId = req.cookies.organizationId;
if (!userId || !organizationId) {
return forbiddenException(res);
}
const organization = await getCurrentOrganization(userId);
if (!organization) {
return forbiddenException(res);
}
const auth = getAuth();
const user = await auth.getUser(userId);
if (!user) {
return notFoundException(res);
}
await auth.setCustomUserClaims(userId, {
...(user.customClaims ?? {}),
organizationId,
});
return res.send({ success: true });
}
export default withPipe(
withMethodsGuard(['POST']),
withAuthedUser,
organizationTokenHandler
);
- Secondly, we write a hook that takes care of calling this endpoint using our
hook
useApiRequest
:
import { useApiRequest } from '~/core/hooks/use-api';
export function useUpdateOrganizationIdToken() {
const path = `/api/organizations/token`;
return useApiRequest(path);
}
- Finally, we call the hook when the user changes the organization. This involves two steps:
- Executing the HTTP request written above
- Hard refreshing the user's custom claims using the Firebase SDK's API
auth.currentUser?.getIdTokenResult(true)
import { useUpdateOrganizationIdToken } from '~/lib/organizations/hooks/use-update-organization-id-token';
// within the Component
const [updateOrganizationIdToken] = useUpdateOrganizationIdToken();
// this is called by your component when the organization ID changes
const onChange = useCallback(async () => {
await updateOrganizationIdToken();
await auth.currentUser?.getIdTokenResult(true);
}, [auth.currentUser, updateOrganizationIdToken]);
return <OrganizationsListBox onChange={onChange} />;
Conclusion
And that's all!
With the code above, we can easily add Group Security to our organizations, teams, projects, and any entity where multiple users are a part of in Firebase.
I hope you enjoyed this post. Please consider subscribing to our newsletter or purchasing a copy of Makerkit, where we took care of all this code for you already.