This is a submission for the Permit.io Authorization Challenge: Permissions Redefined
If you want to immediately get setup. Follow the instructions at https://github.com/AnsellMaximilian/permishout
What I Built
For this challenge, I decided to recreate Twitter/X, complete with access control. It's called PermiShout. Where a Shout
is like a Tweet
or post. I thought this would be perfect to show off Permit.io's base capabilities.
Here's the app workflow:
Home Page
Login/Signup
Complete Profile
Create Shout
Look at Profile
Follow/Unfollow
Reply to and Delete Shouts
As you can see some shouts are different than others. Some have a delete button -- that's because Permit.io has determined that the current user has the authorization to do that.
Some reply buttons are disabled and some are not. That's also Permit.io doing the work. Here's some conditions that has been set up for a user to be able to reply:
- If the shout reply mode is set to "everyone" then any one can reply
- If the shout reply mode has been set to "verified accounts only" then only "admins" can reply
- If the shout reply mode has been set to "people mentioned" then only people mentioned will be able to reply
- Everyone the user is following can reply
Demo
Project Repo
Check out the repo here
My Journey
I have to say it wasn't easy learning all the necessary topics about Permit.io. It is definitely robust and full of features you would expect from an access control library.
But will say that one of the most frustrating but intriguing part was learning about integrating Permit.io on the frontend using the permit-fe-sdk
.
I finally got it to work, and it's definitely one of my favorite parts of Permit.io. And I got a custom reusable context provider out of this to use for future projects using Permit.io. Custom Permission Context Provider
Overall, this was my first time using a third party access control service and from what I've seen it's very robust and complete.
In my head I keep comparing it with the ease of access control/authorization in Laravel. Which is great! It's one of the main reasons I love using Laravel even though NextJs is my favorite framework.
Using Permit.io for Authorization
The way I'm going to structure this section is by going through the development as if I'm developing Permishout from scratch. Letting you know what decisions need to be made while developing with Permit.io. This way, you'll be able to apply my journey into your own while developing your own apps with Permit.io.
Here are a bunch of things that I want to highlight:
- Planning and Setting Up
- Syncing Users
- Doing checks in the backend
- Using
permit-fe-sdk
for easily displaying things based on access control
Planning and Setting Up
User Stories
So first I want to identify user stories that I want for this app:
- User authentication.
- Authenticated users have
profile
- All authenticated users will be able to
create
anyshout
and also view it - The
shouter
(poster/author) of ashout
will be able todelete
andreply
to it. - Users with the role
admin
will be able todelete
anyshout
- Authenticated users can follow/unfollow each other.
- In Twitter/X you can set which users will be able to reply to your Tweet/Post. I want to emulate this feature, as it will show off a lot of the capabilities in Permit.io. Here's how Twitter/X handles it:
- Everyone: everyone can reply to a
shout
- People mentioned: only people
mentioned
in ashout
canreply
. - People you follow: only people you follow can
reply
. However to demonstrate "role derivations", I'm going to make it so people you follow can always reply to any of yourshout
s. - Verified users only: For PermiShout, it's going to be people with
admin
roles.
- Everyone: everyone can reply to a
Identifying Components
Now that we have our user stories, we can start identifying Permit.io components we are going to need.
Here's what I got:
- Roles:
admin
Resources:
profile
This resource will just be an anchor point of many of the functionalities I'm going to implement. It's not going to hold any attributes, but it will help me identify followers/following as well as role derivations. This will be explained later.shout
Resource instance roles:
Just a little explanation on what instance roles are: Admin would be a Top Level Role, where authorizations on it will be applied to every instance of a resource. So if an Admin hasdelete
access to ashout
, it will have that on every one.
An instance role, however, only applies to a single instance of a resource type. Here's how Permit.io structures it: resource:resource_key#role
, meaning actions available to the role owner will only apply to resource with the key resource_key
.
Here are the roles I have identified:
-
profile#owner
Just the owner of a profile. Just like Twitter having you complete some data before you can Tweet. profile#follower
profile#followed
-
shout#shouter
: canreply
anddelete
a shout -
shout#replier
: canreply
to ashout
This will be an important role. Since I'm going to let people you follow have the ability toreply
. I will derive this from the roleprofile#followed
. shout#mentioned
Setting Up
There are a bunch of ways of setting this up. The README.md
of my repo will explain it thoroughly, but I'll be brief here and just explain things that might be hard to grasp at first. We'll be using this permit
object below. Remember to not use this on the frontend.
import { Permit } from "permitio";
const permit = new Permit({
token: process.env.PERMIT_SDK_KEY,
pdp: process.env.PERMIT_PDP_URL,
apiUrl: process.env.PERMIT_API_URL || "https://api.permit.io",
});
export default permit;
Here's how you would programmatically create an admin
role:
await permit.api.createRole({
key: "admin",
name: "Admin",
permissions: ["shout:delete"],
});
Just a note, here's the syntax for permissions: resource#action
. This is basically saying, "Hey, create an admin role, which will be able to do delete on shouts. Since this is a top level role, it will apply to all instances of shouts.
Here's how you create a shout
resource, along with its available instance roles:
await permit.api.createResource({
key: "shout",
name: "shout",
actions: {
delete: {},
reply: {},
},
// Resource roles are used to define the permissions for each role on the resource
roles: {
shouter: {
name: "Shouter",
permissions: ["delete", "reply"],
},
replier: {
name: "Replier",
permissions: ["read", "reply"],
},
mentioned: {
name: "Mentioned",
permissions: ["read", "reply"],
},
},
As you can see I've created 3 unique roles here. Even though they have the same permissions, I've decided to separate them just in case I need to add more actions. Basically this code says "Hey, I want to create a resource called 'shout'. It can have 2 actions performed on it: delete and reply. A user can be a replier, mentioned, and a shouter. All 3 of which will be able to perform all available actions.
Here's how you would create a relationship between resources. This is very important. Role derivations are extremely powerful, and relations are needed for it.
await permit.api.resourceRelations.create("shout", {
key: "parent",
name: "Parent",
subject_resource: "profile",
});
The above snippet basically is saying, "Hey, create a relation for shout. The subject of this relationship is profile. I want profiles to be parents of shouts."
I got confused by this at first, so I'll try to explain it just in case you are too. Basically, imagine in your head what relationship between two resources do you want to have.
Here we have shout
and profile
. We'll I want to connect these as such that a profile
will have many instances of shout
. Let's go with "Profile is the parent of shouts".
subject_resource
is thename
offirst parameter of create
Above is how it would translate to parameters.
IMPORANT: the
name
parameter can be anything. It doesn't even have to realistically describe the relationship (but it should). So I could've putowns
as thename
parameter just the same.
Finally, creating resource derivations. I want EVERYONE who follows a user to be able to reply to all their shouts
. So, let's derive profile#followed
to shout#replier
.
IMPORANT: relationships are integral to derivative relationships. The above rule I want to implement means that users will have the
shout#replier
role ONLY if he hasprofile#followed
on a profile AND if that profile is a parent of theshout
in question.
Here's how I created that derivation in code:
await permit.api.resourceRoles.update("shout", "replier", {
granted_to: {
users_with_role: [
{
linked_by_relation: "parent",
on_resource: "profile",
role: "followed",
},
],
},
});
Pretty simple. This is basically saying, "Hey, update this shout#replier role you created earlier and grant this role to users with the followed role on a profile. And only grant this replier role IF the profile has a parent relationship with the shout in question"
Syncing Users
Obviously permit.io doesn't manage your authentication. You will need a your own or a one from a third party. Like Clerk. However, this does not mean you can neglect managing users in permit.io.
Permit.io doesn't automatically know your users from your authentication system. Let's use Clerk for example as that's the library I'm using in my app.
After you've created your user in Clerk. You need also create them in Permit.io. You can do this in your login/signup logic, or you can create a separate process entirely. You know how in Twitter/X, after you sign up with your email or Google account, you still have to fill up things like username, name, etc? I'm going to take this opportunity to sync my users to permit.io
So, if a user has logged in using Clerk, and there isn't a matching entry in Permit.io, I'm going to force them to complete their profile.
Middleware in NextJs
export default clerkMiddleware(async (auth, req) => {
const userId = (await auth()).userId;
const { origin, pathname } = req.nextUrl;
if (isProtectedRoute(req)) await auth.protect();
// get project_id and environment_id
const { project_id, environment_id } = await permitApi
.get("/v2/api-key/scope")
.then((res) => res.data);
// check if user's profile is complete (based on their existence in Permit)
let isProfileComplete = false;
try {
await permitApi.get(
`/v2/facts/${project_id}/${environment_id}/users/${userId}`
);
isProfileComplete = true;
} catch {
isProfileComplete = false;
}
// if it's a public route, the user is signed in and the profile is complete, redirect to home
if (isPublicRoute(req) && isProfileComplete)
return NextResponse.redirect(`${origin}/home`);
// if the user has not completed their profile and is trying to access a protected route, redirect to profile creation page
if (
!isProfileComplete &&
isProtectedRoute(req) &&
!pathname.startsWith("/profile/create")
) {
return NextResponse.redirect(`${origin}/profile/create`);
}
});
Here's a snippet of my middleware. This will run on most pages you go to on PermiShout. Basically, what it does is this:
- Get the currently logged in user's id using
(await auth()).userId
- If the user has not even registered or signed in to Clerk, we can just protect our protected routes with
await auth.protect()
- If the user has been authenticated (by clerk), we check if they have a matching entry in permit.io We check this by using the following code:
// note that `permitApi` is just an instance of `axios`
await permitApi.get(
`/v2/facts/${project_id}/${environment_id}/users/${userId}`
);
- If that api call succeeds, that means the user has a matching
user
in Permit.io. We're good. The user can access any protected and non public-only routes. - IF the api call fails, that means the user has no matching
user
. Permit.io doesn't know about this user. Now we force the user to go to the/profile/create
route. Where I will be forcing them to create an account.
Now after filling in some info, mainly username, name, country, and year born, we handle
SYNCING USERS
in the backend or a route handler as we're using NextJS.
const POST = async (request: NextRequest) => {
const { userId } = getAuth(request) || "";
const user = await clerkClient.users.getUser(userId || "");
const { username, name, yearBorn, country } = await request.json();
const { firstName, lastName } = splitName(name);
await clerkClient.users.updateUser(userId || "", {
firstName,
lastName,
});
const { key: createdUser } = await permit.api.syncUser({
key: userId || "",
first_name: firstName,
last_name: lastName,
email: user?.emailAddresses[0].emailAddress || "",
attributes: {
username,
yearBorn: parseInt(yearBorn),
country,
},
});
await permit.api.roleAssignments.assign({
user: createdUser,
role: "owner",
resource_instance: `profile:profile_${createdUser}`,
tenant: "default",
});
return NextResponse.json({
success: true,
});
};
I'll explain a little bit what's happening here:
- First, we get the currently authenticated user from Clerk (reminder that this is not a user from Permit.io -- That's what we're here for!)
- Then we get all the extra attributes we want to put in Permit.io
- Then--and Permit.io makes this REALLY easy for us-- we do
permit.api.syncUser
. Remember to match the user key for Permit.io with theid
of your Clerk user. You don't necessarily have to match it, but it will be infinitely HARDER if you don't. For example, if you decide to use only the first 10 characters of your Clerkid
, you will need to get those first 10 every time you reference your Permit.io user.
You can also add email
, first_name
, and last_name
. This will also be your opportunity to add any extra attributes.
Note: I said Permit.io makes this REALLY easy because you don't have to differentiate between an existing user and one that hasn't been created.
syncUser
will handle it. If it exists, it will overwrite it (sync it), and if it doesn't, it will create it.
- Now, since I have the
profile
resource set up. I want to also associate each user with one. So that's where this line is for:
await permit.api.roleAssignments.assign({
user: createdUser,
role: "owner",
resource_instance: `profile:profile_${createdUser}`,
tenant: "default",
});
What this code does two-fold. First, it creates a resource instance of profile
with the key profile_${created_user_key}
. Something important to remember is that resource_instance
has to be a string in the format resource:instance_key
.
Secondly, this code also assigns a role to user with the createdUser
key and assigns a role owner
to it (the profile
instance).
So now, this particular user is officially the owner of their profile and all the access control that comes with that instance role on that particular profile as well as any role derivations.
I just wanted to mention this because making role assignment and instance created in one function is really neat!
Doing Checks
An important thing to note is that checks should always be done, as in you will need to query them. Permit.io doesn't know about your code and what you're doing, they just make writing the code for checking permissions way easier. Here's an example check I do whenever a user is deleting a shout
.
const isUserAllowedToDelete = await permit.check(userId || "", "delete", {
type: "shout",
key: shout.key,
});
Really simple isn't it? Instead of checking multiple ALL the scenarios where a user will be allowed to delete a particular shout, we just ask Permit.io! Here, it will return true
if user is an admin
or if user is the owner
of the shout
.
These two conditions are simple. But imagine if you have multiple unique ones you have to keep track of. Imagine if a user can also be a low level moderator to delete. You have to write that check and add them to ALL parts of your code who need the check.
Anyway, if isUserAllowedToDelete
is true
, we proceed to delete the instance using this:
await permit.api.resourceInstances.delete(`shout:${shout.key}`);
Easy!
Using permit-fe-sdk
This is my favorite part. Honestly figuring out how the whole thing works felt great! Basically, this library helps you in the front end in determining whether or not a user is allowed to do something.
A similar concept exists in Laravel's blade directives:
@can('update', $post)
Delete
@endcan
When Permit.io works with CASL, you can achieve the same function. But this time this is all happening in the frontend -- SAFELY!
Here are the steps. These were really hard for me to grasp and figure out, so hopefully I can explain it well:
- Install the necessary packages
npm install @casl/ability @casl/react permit-fe-sdk permitio
- Create a POST route handler where the
permit-fe-sdk
will be calling for permission checks.
Based on my own research, you want to create POST routes if you're trying to get bulk permissions. Meaning, you check for many permissions at once.
Here's mine (I'll be focusing on bulk permissions):
export async function POST(req: NextRequest) {
try {
const { searchParams } = req.nextUrl;
const userId = searchParams.get("user");
const { resourcesAndActions } = await req.json();
if (!userId) {
return NextResponse.json(
{ error: "No userId provided." },
{ status: 400 }
);
}
const checkPermissions = async (resourceAndAction: {
resource: string;
action: string;
userAttributes?: PermishoutUserAttributes;
resourceAttributes?: Record<string, any>;
}) => {
const { resource, action, userAttributes, resourceAttributes } =
resourceAndAction;
const [resourceType, resourceKey] = resource.split(":");
return permit.check(
{
key: userId,
attributes: userAttributes,
},
action,
{
type: resourceType,
key: resourceKey,
attributes: resourceAttributes,
tenant: "default",
}
);
};
const permittedList = await Promise.all(
resourcesAndActions.map(checkPermissions)
);
return NextResponse.json({ permittedList }, { status: 200 });
} catch (error) {
console.error("Permission check error:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}
I find it useful to mention the end goal of this route. It's basically to return an object with permittedList
as an array of booleans. Now you could easily just return a list of 100 true
s and you'd pass all the check. But that's not what we're here for right?
We'll get to the frontend a little later, but basically we want to map resourcesAndActions
to permission results. This variable will be an array of resources and actions queried from the frontend.
Each array element will have resouce
, action
, userAttributes
and resourceAttributes
. You'll want to accurately test for these permissions.
IMPORTANT: notice that I split the resource and resource key? This is because you supply them from the frontend in this format
resource:resource_key
. And you get it in this route as is, so you have to manually split it and then put the resource intype
and the key inkey
. This stumped me for a while.
Okay so based on all the actions and resources we do permit.check
so it will return the boolean array in the correct order.
IMPORTANT: Remember that order is very very important. You can't just be randomly doing
permitionList.reverse()
. The frontend wouldn't be accurate anymore in determining permissions.
Okay so we have return that list of booleans. Now what?
- Setup the frontend to be able to use that route
const defaultActionResources: ActionResourceSchema[] = [
{
action: "delete",
resource: "shout",
},
];
export const AbilityContext = createContext<
| {
ability: Ability<AbilityTuple, MongoQuery>;
setActionResources: React.Dispatch<
React.SetStateAction<ActionResourceSchema[]>
>;
}
| undefined
>(undefined);
export const useAbility = () => {
const ability = useContext(AbilityContext);
if (!ability) {
throw new Error("useAbility must be used within an AbilityProvider");
}
return ability;
};
export const AbilityLoader = ({ children }: { children: ReactNode }) => {
const { isSignedIn, user } = useUser();
const [ability, setAbility] = useState<Ability<AbilityTuple, MongoQuery>>(
new Ability()
);
const [actionResources, setActionResources] = useState<
ActionResourceSchema[]
>(defaultActionResources);
useEffect(() => {
(async () => {
// reset it first
setAbility(new Ability());
if (isSignedIn) {
const permit = Permit({
loggedInUser: user.id,
backendUrl: "/api/permit/check",
});
permit?.reset();
const allActionResources = [
...actionResources,
...defaultActionResources,
];
await permit.loadLocalStateBulk(allActionResources);
const caslConfig = permitState.getCaslJson();
const caslAbility =
caslConfig && caslConfig.length
? new Ability(caslConfig)
: new Ability();
setAbility(caslAbility);
}
})();
}, [isSignedIn, user, actionResources]);
return (
<AbilityContext.Provider value={{ ability, setActionResources }}>
{children}
</AbilityContext.Provider>
);
};
Basically we want to provide an Ability
object throughout the whole app. I'm using Context.Provider
for this.
Remember actions and resources from the route? That maps directly to the ones in this context provider.
IMPORTANT: The check you will be able to do directly corresponds to what you supply to
await permit.loadLocalStateBulk(allActionResources)
. If you supply it with one pair of resource and action, it will not be able to accurately check for others.
I have created a local state called actionResources
, which will cause the useEffect
to update and load the local state with the latest permission.
I have some default permissions I will always want to check: admin permissions. That's why I have some default action resources.
IMPORTANT: Make sure to reset it also. I've found problems if I just call
loadLocalStateBulk
over and over.
That's it! Just make sure you update the ability
variable with the most recent permissions. Now you can use useAbility
to get that ability and perform checks.
- Actually using it in the frontend
Let's focus on shout permissions. Admin will always be able to
delete
shout
s so let's put that in the default array.
Now, shouts need fine-grained control as there are roles and permissions exclusive to single instances of shout
.
Here's how how handled it. Remember that I exposed setActionResources
in that provider and that if that array is updated, it will update local permissions. We'll that's what I'm going to do in the home
page.
const { setActionResources } = useAbility();
useEffect(() => {
const shoutActions: ActionResourceSchema[] = shouts.flatMap((shout) =>
[
{ action: "reply", resource: `shout:${shout.key}` },
{ action: "delete", resource: `shout:${shout.key}` },
]);
setActionResources(shoutActions);
}, [shouts, setActionResources]);
That's it. This will update all our permission in the frontend so we can use them. In each shout
I want to have a delete button for users who have the permission. I can use the Can
component from CASL:
import { Can } from "@casl/react";
<Can I="delete" a={`shout:${shout.key}`} ability={ability}>
<Button
style={{ padding: 0 }}
variant="ghost"
className="flex items-center gap-1 text-red-400 hover:text-red-500 ml-auto"
onClick={(e) => {
e.stopPropagation();
if (setShoutToDeleteKey) setShoutToDeleteKey(shout.key);
}}
>
<Trash size={16} />
<span>Delete</span>
</Button>
</Can>
That's it. That's all I need to do. Just supply it with the action and the resource as well as the ability
object I mentioned.
You can also do a standard inline check like this:
import { permitState } from "permit-fe-sdk";
const canReply =
shout.replyMode === ShoutReplyType.EVERYONE ||
permitState?.check("reply", `shout:${shout.key}`, {});
Conclusion
Sorry for the tangent. I was super excited getting this working on my own, especially the front end sdk. Hopefully they will keep working on it because I love Laravel blade directives. And this is exactly that in React
!