DEV Community

Cover image for Build a React File Sharing App with Granular Access Controls (ReBAC)🔥🔒
Astrodevil
Astrodevil

Posted on • Edited on • Originally published at astrodevil.Medium

Build a React File Sharing App with Granular Access Controls (ReBAC)🔥🔒

TL;DR

Learn to build a React file-sharing app implementing Relationship-Based Access Control (ReBAC) using Permit.io to manage granular user permissions (like owner, viewer) for specific files.

  • ReBAC defines permissions based on the relationship between users and resources. ReBAC is utilized to determine who can share what file.

  • Appwrite is a backend-as-a-service platform that provides authentication, storage, and database. Appwrite is used for authentication and storage

  • Permit.io is a fullstack authorization-as-a-service that helps you build and enforce permissions in your applications.

start gif


File-sharing applications have become essential tools for collaboration and information exchange. From collaborating on documents to sharing files, these applications play a crucial role in how we communicate.

However, one challenge remains: how do we know what file a user has access to and what can the user do with that file?

To address this issue, relationship-based access control (ReBAC) is an effective authorization model to enforce permissions in a file-sharing application. ReBAC allows us to define the relationship between a user and a particular file and enforce access rules based on that relationship.

In this tutorial, we’ll build a simple file-sharing application with the ReBAC authorization model using:

  • React for the frontend,

  • Appwrite for authentication, database, and storage, and

  • Permit.io for granular access control with relationship-based access control (ReBAC).

Before we go on with building the app, let’s understand what ReBAC is and the architectural design of our application.

What we’ll build

Our application is a file-sharing application. Users can upload and share files with other users.

What access policy we’ll be implementing

As stated earlier, we'll be using the relationship-based access control (ReBAC) authorization model for our file-sharing app.

What does this mean in terms of granting permissions?

It means that permissions are granted to a user based on their relationship to a particular file instance. For example, only users who have the 'owner' relationship with a file can perform the share action on that specific file. Similarly, users with the 'viewer' relationship to a file are granted view and download permissions for that exact file.

Desired application flow

In our file-sharing application, we’ll use Permit’s SDK to implement the file sharing feature and Appwrite for authentication, file upload, and cloud function (for interacting with Permit SDK).

Here’s our application flow:

When a user:

  • registers on our application, the user is synced to Permit through Appwrite cloud function.

  • uploads a file, a resource instance of that file is created in our Permit dashboard

  • wants to share a file with other users, we’ll use the Permit SDK’s permit.check to check if the user has the appropriate permission

Additionally, a user will specify the user to share with and what role to assign the user.

Prerequisites

To follow along with this tutorial, I assume you have:

  • Basic knowledge of JavaScript and React.

  • Node.js is installed on your machine.

Tech stack

Let's go over the tech stack we'll be using to build the file-sharing app.

React

React is a JavaScript library for building single-page frontend applications.

Appwrite

Appwrite is an open-source backend-as-a-service platform similar to Firebase and Supabase. Appwrite offers features such as authentication, database, storage, messaging, and cloud functions. For this tutorial, we'll be using the:

  • authentication,

  • database,

  • storage, and

  • cloud functions.

Permit.io

Permit.io is an authorization-as-a-service platform that lets you create and manage permissions separately from your application code.

Setting up the Development Environment

In this section, we'll set up our development environment.

Use the following commands to create a new React project and install the needed dependencies.

Create a React app with Vite:

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

Follow the prompts to set up your project. For this tutorial, we'll use TypeScript.

Install the necessary dependencies:

npm install react-router-dom lucide-react appwrite
Enter fullscreen mode Exit fullscreen mode

Setting Up the Appwrite Backend

In this section, we'll set up our Appwrite backend for authentication, database, and storage.

Setting up Appwrite:

  • Go to Appwrite and create an account if you don't have one.

  • Set up your organization.

  • The free plan is enough for the needs of our application.

Click on "Create project" to create a new project. Provide the name of the project and click on "Next".

project

For the deployment region, we'll use the default region. Click on "Create" to create the project.

region

Since our application is a web application, select the "Web" platform.

app

Provide the name of your project and the host name, "localhost". Click on "Next".

permit ss

Skip the optional steps.

Setting up authentication

We'll use email/password for our authentication.

On the left panel, click on "Auth" and enable "email/password"

auth

You can limit the number of sessions of a user. This means how many places a user can be logged in. For our application, we want a user to have only one session.

To accomplish this, go to the "Security" tab on the "Auth" section and limit sessions to one.

security tab

Setting up database

The database will be used to store the metadata of each file a user uploads.

Go to the "Database" section in the left panel and click on "Create database".

db

Provide the name "file_db" and click on "Create". Don't enter the Database ID, Appwrite will do that for us.

appwrite

After creating the database, you'll be redirected to the "Collections" page. Click on "Create collection".

appwrite db

Provide the name of the collection "file-metadata" and create it.

file-metadata

After creating the collection, you'll be redirected to the "file-metadata" document page. Go to settings tab and scroll down to the "Permissions" and "Document security". Check all the permissions for "Users" and enable the document security, which allows only the user who uploaded the file can perform the allowed operations.

appwrite-file

For the "file-metadata" document, we need to create attributes. Attributes are like schemas of what the document stores. Without attributes, you can't upload data to the database.

Go to "Attributes" tab and create four attributes:

  • fileName (string, 256, required)

  • fileId (string, 256, required)

  • ownerId (string, 256, required)

  • shared_with (array of string, 1024)

attributes

Setting up storage

The Appwrite Storage will be used to store the actual files that users upload. These files can be anything, ranging from docs, images, scripts, to videos.

Go to the "Storage" on the left panel and click on "Create bucket"

storage image

Provide the name "files" and click on "Create".

dashboard picture

After creating the bucket, you'll be redirected to the "files" bucket. Go to the "Settings" tab and add permissions for "Users"

appwrite setting

Now we are done with setting up our Appwrite authentication, database, and storage, let's set up our authorization.

Setting up our Authorization

Planning our ReBAC implementation

Before we go into setting up our Permit setup, let’s map out our ReBAC structure clearly.

Resources and actions

Our file sharing app will only manage one resource:

  • files: Actions include: share, download, delete, and view.

Relationships and actions

The following table defines the relationships users can have with file resource instances and the permissions granted based on those relationships.

Relationship File resource
Owner Full access (share, download, view, download)
Viewer View and download

This is the permission structure of our simple file sharing app where:

  • File owners have full control over their files.

  • Viewers (users who a file is shared with and assigned “viewer” relationship) can only view and download files.

It's important to note that in ReBAC, these relationships are typically defined and enforced at the level of individual resource instances (in our case, each specific file).

Now, with the structure out of the way, let’s look at setting up the authorization structure in Permit.io.

Creating a Permit project

To use Permit.io, you’ll have to create a free account. After creating your account, go to the “Project” section in your dashboard and create a new project.

permit dashboard

Then copy your API key.

apis

After creating your environment, go to your dashboard and go to the "Policy" section on the left panel.

Select the "Resources" tab and click on "Add Resource". This will open a right form panel.

resource-tab

Enter the name of the resource (“file”) and assign the actions (“delete”, “download”, “share”, “view”). Scroll down to the “ReBAC Options” to define the roles for each resource (“owner”, “viewer”), then click “Save”.

file-str

Go to the "Policy Editor" tab and set up the permissions for the roles you just created.

  • Owner: has all permissions

  • Viewer: has permissions to only view and download file resources

roles

Congratulations, we have set up Permit.io for our authorization.

Next, we are going to set up our application.

Frontend Setup with React

In this section, we're going to continue the set up for our application.

Configuring Appwrite in our React frontend

Retrieve the following from your Appwrite backend:

  • project ID

  • database ID

  • file-metadata collection ID

  • files bucket ID

Create a .env file in the root directory of your React app. Inside the .env file, add the API keys you retrieved from your Appwrite backend


VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
VITE_FILE_METADATA_COLLECTION_ID=your-file-metadata-collection-id
VITE_DATABASE_ID=your-database-id
VITE_FILES_BUCKET_ID=files-bucket-id
Enter fullscreen mode Exit fullscreen mode

Inside the src directory, create a file configuration/appwrite.ts and add the following code:

import { Client, Account, Databases, Storage } from "appwrite";

export const client = new Client()
  .setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
  .setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID as string);

export const account = new Account(client);

export const database = new Databases(client);

export const storage = new Storage(client);
Enter fullscreen mode Exit fullscreen mode

In the code above, we're setting up our Appwrite for authentication, database and storage.

Configuring Permit.io in our application

Permit only has server-side SDKs at the moment. This means that to integrate Permit’s ReBAC policy in our React application, we’ll need some sort of backend. We’ll use Appwrite cloud functions to integrate Permit in our React application.

Don’t let cloud functions scare you, they’re pretty straightforward to set up.

Navigate to your Appwrite console and select the “Functions” tab on the left panel. Click on “Create function” to create a new Appwrite function.

appwrite-console

Click on ”All starter templates”

template

Click on “Create function”

create-func

Add the name of your function and choose Node.js for your function’s runtime. The “0.5 CPU, 512 MB RAM” is enough for this tutorial.

Click on “Next”

next

Leave the permissions as is and click on “Next”

next-1

In the “Deployment” section, choose “Connect later” — because we’ll use the CLI to develop and deploy our functions.

And click on “Create”.

create-permit

Setting up our Appwrite function in our CLI

Navigate to your project directory in your terminal and run the following command to install the Appwrite CLI:

npm install appwrite-cli
Enter fullscreen mode Exit fullscreen mode

You’d have to sign in using your Appwrite credentials:

appwrite login
Enter fullscreen mode Exit fullscreen mode

Note: if you signed up to Appwrite using OAuth (GitHub or Google), you wouldn't have a password to login with in the CLI. To get your password, go to your account settings and put in a password.

Run the following command to initialize an Appwrite project:

appwrite init project
Enter fullscreen mode Exit fullscreen mode

Choose “Link directory to an existing project” and follow the prompts.

Initialize an Appwrite function using the following command:

appwrite init function
Enter fullscreen mode Exit fullscreen mode

and follow the prompts to set up your function

Run the following command to pull in the function code into your application folder:

appwrite pull function
Enter fullscreen mode Exit fullscreen mode

Let’s understand how our Appwrite cloud function will work.

There are three main ways of executing an Appwrite cloud function:

  • through HTTP requests

  • event triggers

  • scheduling

We’re going to call our Appwrite function using HTTP and event triggers.

Our Appwrite function executes when a user:

  • signs up in our application

  • uploads a file

  • attempts to share a file

  • visits the file upload page

Now we are on the same page of how our application integrates with the Appwrite function, let’s move on with the application.

The Permit.io SDK requires an API key. To get your API key, go to the “Settings” section of your Permit.io dashboard and click on "API Keys".

Copy your API key, create a .env file inside of the directory housing your Appwrite function, and add the API key as PERMIT_API_KEY.

Permit.io requires a Policy Decision Point (PDP) to evaluate your authorization requests. Permit.io provides several ways to set up a PDP, but we'll set up our PDP using Docker (this is the recommended way by Permit.io).

You need to have Docker installed on your machine.

In your Permit.io dashboard, click on "Projects" in the left panel and click on "Connect" in your preferred environment.

dashboard2

Pull the PDP container from Docker Hub using the following command:

docker pull permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

Run the container:

docker run -it \\\\
  -p 7766:7000 \\\\
  --env PDP_API_KEY=your-permit-api-key \\\\
  --env PDP_DEBUG=True \\\\
  permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

Replace the PDP_API_KEY with your API key.

Note: if the above command fails to run, put the command in a single line like this:

docker run -it -p 7766:7000 --env PDP_API_KEY=your-permit-api-key --env PDP_DEBUG=True permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

The PDP is now running on http://localhost:7766. Navigating to the URL, you should see a message like this:

{
  "status": "ok"
}
Enter fullscreen mode Exit fullscreen mode

With Permit.io configured, update the functions/"your function name"/src/main.js file with the following code:

const { Client, Users } = require('node-appwrite');
const { Permit } = require('permitio');

const permit = new Permit({
  token: process.env.PERMIT_API_KEY_PROD,
  pdp: 'PDP-endpoint',
});

// This Appwrite function will be executed every time your function is triggered
module.exports = async ({ req, res, log, error }) => {
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT || '')
    .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID || '')
    .setKey(req.headers['x-appwrite-key'] ?? process.env.APPWRITE_API_KEY);

  const users = new Users(client);
}
Enter fullscreen mode Exit fullscreen mode

As a side note, you’ll have to set the type property in your package.json to commonjs. I’ll be using main.js to reference the Appwrite cloud function.

Additionally, get your Appwrite API key and add it to the .env file.

Additionally, make sure to add the necessary events to your Appwrite function like this:

gif21

Building our application

This is the section where we'll build out our application.

Setting up authentication

In this section, we'll set up authentication for our app.

Here's what we want to achieve with our authentication system:

  • When a user signs up in our application, we'll sync the user to Permit.io.

Create a new file, context/context.ts. In this file, add the following code:

import { createContext, useContext } from "react";

interface ContextValue {
    user: object | null;
    loginUser: (email: string, password: string) => void;
    logoutUser: () => void;
    registerUser: (email: string, password: string, name: string) => void;
    checkUserStatus: () => void;
    loading: boolean;
}

export const AuthContext = createContext<ContextValue | null>(null);

export const useAuth = () => useContext(AuthContext);
Enter fullscreen mode Exit fullscreen mode

In the code above, we're creating an auth context using the React Context API to manage authentication. The auth context has methods for signing in, registration, logging out, and a user's session.

Update the App.tsx file with the following code:

function App() {
  const [loading, setLoading] = useState<boolean>(true);
  const [user, setUser] = useState<object | null>(null);

  console.log(user);

  // Login the user
  const loginUser = async () => {}

  // Logout the current user
  const logoutUser = async () => {}

  // Register a new user
  const registerUser = async () => {}

  // Check the status of the current user
  const checkUserStatus = async () => {}

  const contextData = {
    user,
    loginUser,
    logoutUser,
    registerUser,
    checkUserStatus,
    loading
  };

  return (
    <AuthContext.Provider value={contextData}>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <BrowserRouter>
          <header className="flex justify-center items-center p-2 mb-4 bg-purple-400">
            <nav>
              <ul className="flex gap-5">
                {user ? (
                  <li>
                    <button onClick={logoutUser}>Log out</button>
                  </li>
                ) : (
                  <>
                    <li>
                      <NavLink to="/login">Sign in</NavLink>
                    </li>
                    <li>
                      <NavLink to="/register">Sign up</NavLink>
                    </li>
                  </>
                )}
              </ul>
            </nav>
          </header>
          <Routes>
            <Route element={<ProtectedRoute />}>
              <Route element={<FileUploadPage />} path="/" />
            </Route>
            <Route element={<LoginPage />} path="/login" />
            <Route element={<SignUpPage />} path="/register" />
          </Routes>
        </BrowserRouter>
      )}
    </AuthContext.Provider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In the App.tsx file, we wrapped our entire application with AuthContext.Provider. Additionally, we added routing using the react-router-dom library.

Looking closely, you'll see that we're protecting our home page. This ensures that only authenticated users can access the home page.

Create a components folder. Inside the folder, create a ProtectedRoute.tsx file and add the following code:

import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/context";

function ProtectedRoute() {
  const { user } = useAuth();
  return user ? <Outlet /> : <Navigate to="/login" />;
}

export default ProtectedRoute;
Enter fullscreen mode Exit fullscreen mode

In the code above, we're creating a ProtectedRoute component that checks for a user and redirects an authenticated user to the login page.

Now, we're going to create the LoginPage, SignUpPage, and FileUploadPage components.

LoginPage

Create a pages folder. Inside the pages folder, create a new file, LoginPage.tsx, and add the following code snippets to create the Login component:

import { NavLink, useNavigate } from "react-router-dom";
import { signIn } from "../configurations/appwrite";
import { FormEvent, useEffect, useState } from "react";
import { useAuth } from "../context/context";

function LoginPage() {
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const navigate = useNavigate();
  const { user, loginUser } = useAuth();

  useEffect(() => {
    if (user) navigate('/');
  });
Enter fullscreen mode Exit fullscreen mode

We’re setting up our Login component by importing required modules and hooks. We initialize state for the email and password inputs, retrieve authentication context via useAuth, and use useEffect to redirect the user if already logged in.

  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  const handleSignIn = async (e: FormEvent) => {
    e.preventDefault();
    const formData = new FormData();
    formData.append("email", email);
    formData.append("password", password);
    const userEmail = formData.get("email") as string;
    const userPassword = formData.get("password") as string;
    loginUser(userEmail, userPassword);
  };
Enter fullscreen mode Exit fullscreen mode

This part of the component includes functions to handle changes for both email and password inputs. The handleSignIn function processes form submission by creating a FormData object and then invoking loginUser with the gathered credentials.

  return (
    <div className="w-full max-w-full flex flex-col justify-center items-center h-screen">
      <form onSubmit={handleSignIn} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
            Email
          </label>
          <input id="email" type="email" placeholder="Email" name="email" value={email} onChange={handleEmailChange} />
        </div>
        <div className="mb-6">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
            Password
          </label>
          <input id="password" type="password" placeholder="Password" name="password" value={password} onChange={handlePasswordChange} />
        </div>
        <div className="flex items-center justify-between">
          <button className="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded" type="submit">
            Sign In
          </button>
        </div>
      </form>
      <p>
        Don't have an account? <NavLink to="/register">Sign up</NavLink>
      </p>
    </div>
  );
}

export default LoginPage;
Enter fullscreen mode Exit fullscreen mode

This final snippet renders the UI through JSX. It displays a form with inputs for email and password, a submit button, and a link to the registration page (in case the user doesn’t have an account in our application).

Here’s the login page UI:

login-page

SignUpPage

Create a new file, SignUpPage.tsx, and add the following code snippets:

import { useState, FormEvent, useEffect } from "react";
import { useAuth } from "../context/context";
import { useNavigate } from "react-router-dom";

function SignUpPage() {
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [name, setName] = useState<string>("");

  const { user, registerUser, loading } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (user) navigate("/");
  }, [user, navigate]);
Enter fullscreen mode Exit fullscreen mode

The code above handles the initial setup: importing hooks and modules, initializing state variables for email, password, and name, and setting up context and navigation. The useEffect hook redirects the user if they are already authenticated.

  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  const handleSignUp = async (e: FormEvent) => {
    e.preventDefault();
    const formData = new FormData();
    formData.append("email", email);
    formData.append("password", password);
    formData.append("name", name);
    registerUser(formData.get("email") as string, formData.get("password") as string, name);
  };
Enter fullscreen mode Exit fullscreen mode

The code above focuses on the functions that update state as the user types and the handleSignUp function, which processes the form submission. It builds a FormData object for clarity before calling the registerUser function.

  return (
    <div className="w-full max-w-full flex justify-center items-center h-screen">
      <form onSubmit={handleSignUp} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
            Name
          </label>
          <input id="name" type="text" placeholder="Your name" value={name} onChange={handleNameChange} required />
        </div>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
            Email
          </label>
          <input id="email" type="email" placeholder="Email" value={email} onChange={handleEmailChange} required />
        </div>
        <div className="mb-6">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
            Password
          </label>
          <input id="password" type="password" placeholder="Your password" value={password} onChange={handlePasswordChange} required />
        </div>
        <div className="flex items-center justify-between">
          <button className="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded" type="submit" disabled={loading}>
            Sign up
          </button>
        </div>
      </form>
    </div>
  );
}

export default SignUpPage;
Enter fullscreen mode Exit fullscreen mode

We’re rendering the JSX for the sign-up form. It includes input fields for name, email, and password, each tied to their corresponding change handlers.

Here’s the sign up page UI:

signup-page

FileUploadP*age*

Create a new file, FileUploadPage.tsx, and add the following code:

This FileUploadPage component allows users to upload files, view file details, and check the upload status.

import { ReactNode, useState } from "react";
import { useAuth } from "../context/context";

function FileUploadPage() {
  const { user } = useAuth();

  const [file, setFile] = useState<File | null>(null);
  const [status, setStatus] = useState<
    "initial" | "uploading" | "success" | "fail"
  >("initial");
  const [isLoading, setIsLoading] = useState<boolean>(false);
Enter fullscreen mode Exit fullscreen mode

The initial setup of our FileUploadPage component:

  • Manages authentication state, selected file, upload status, and loading state

  • Tracks file selection progress and UI states

function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
  if (e.target.files) {
    setStatus("initial");
    setFile(e.target.files[0]);
  }
}

async function handleUpload() {
  if (file) {
    setStatus("uploading");
    setIsLoading(true);
    const fileData = new FormData();
    fileData.append("file", file);
    console.log(file);
  }
}
Enter fullscreen mode Exit fullscreen mode

In this part of the code, we’re handling file selection from the file input field.

return (
    <div className="py-8 px-8 flex flex-col gap-1 items-center">
      <h1 className="text-2xl font-bold my-4">Welcome, {user.name}</h1>
      <p className="text-base font-semibold my-3">
        Upload, view, and share files with your friends
      </p>
      <form>
        <input
          type="file"
          name="file"
          id="file"
          onChange={handleFileChange}
          className="text-center cursor-pointer"
        />
      </form>

      <hr className="border w-full border-black my-8" />

      {file && (
        <section className="border-b w-1/3 text-wrap shadow-sm px-4 border-black my-4">
          <ul className="flex flex-col gap-2 my-3">
            <li className="font-semibold">{file.name}</li>
            <li className="text-gray-400">{(file.size / 1000).toFixed(2)}KB</li>
          </ul>
        </section>
      )}

      {file && (
        <button
          onClick={handleUpload}
          className="bg-blue-500 px-3 py-2 font-semibold cursor-pointer text-white rounded-md hover:bg-blue-300 transition-all"
          disabled={isLoading}
        >
          Upload file
        </button>
      )}

      <Result status={status} />

      <hr className="border w-full border-black my-8" />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We’re returning the JSX for the UI.

function Result({ status }: { status: string }): ReactNode {
  if (status === "success") {
    return <p>âś… File uploaded successfully</p>;
  } else if (status === "fail") {
    return <p>❌ File upload failed</p>;
  } else if (status === "uploading") {
    return <p>⌛ Uploading selected file..</p>;
  } else {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Result component handles the conditional rendering of status messages, by providing visual feedback using emoji indicators.

Next, we're going to create the loginUser, registerUser, checkUserStatus, and logoutUser functions.

Creating the authentication functions

loginUser function

In the App.tsx file, update the loginUser function with the following code:

const loginUser = async (email: string, password: string) => {
    setLoading(true);
    try {
      await account.createEmailPasswordSession(email, password);
      const loggedinUser = await account.get();
      setUser(loggedinUser);
    } catch (error) {
      console.error(error);
    }
    setLoading(false);
  };
Enter fullscreen mode Exit fullscreen mode

The loginUser function accepts email and password as arguments and creates a session using Appwrite's createEmailPasswordSession method on the account object. After a session is created, we set user to loggedinUser.

registerUser function

When registering users in our application, we need to sync them to Permit.io.

As a refresher, Permit.io doesn't have a client SDK and we would be using our Appwrite cloud function to interact with the Permit.io SDK.

In the main.js of our Appwrite function, add the following code:

const data = req.bodyJson;

  if (!data) return res.json({ ok: 'failed', message: 'No data found' });

  const headers = req.headers;

  const triggerType = headers['x-appwrite-trigger'];

  if (triggerType === 'event' && data?.email) {
    try {
      const user = {
        key: data?.email,
        email: data?.email,
        first_name: data?.name,
        last_name: '',
        attributes: {
          tenant: 'default',
        },
      };
      const syncedUser = await permit.api.users.sync(user);
      if (!syncedUser) throw new Error('An Error occurred while syncing user');
      log(syncedUser);
      return res.json({
        ok: true,
        message: `${data?.name} synced successfully`,
      });
    } catch (error) {
      return res.json({
        ok: false,
        message: `${error instanceof Error ? error.message : 'An unknown error occurred'}`,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we’re listening for user registration in our application and syncing the registered user to our Permit dashboard.

Let’s understand what’s going on behind the scenes.

An Appwrite cloud function accepts a context object with four properties, res, req, log, and error, which we destructured.

When an Appwrite function is triggered, the req object receives two fields: headers (for getting useful information on the function execution such as the trigger type and executing user) and bodyJson (which contains the data from executing the Appwrite function).

We’re checking if the trigger type is an event, and if the data object contains an email field (this is so that we know that what triggered the function is user registration).

If true, we construct a user object using the user’s email and name and pass the user object to the sync() method, which creates a new user in Permit.io.

Navigate to the App.tsx file and update the registerUser function with the following code:

const registerUser = async (email: string, password: string, name: string) => {
    setLoading(true);

    try {
      const resultFromUserRegistration = await account.create(ID.unique(), email, password, name);
      await account.createEmailPasswordSession(email, password);
      const registeredUser = await account.get();
      setUser(registeredUser);
    } catch (error) {
      console.error(`An error occurred: ${error}`);
    }
    setLoading(false);
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we're registering a user to our Appwrite backend with account.create() and syncing the user to Permit in the process due to our Appwrite cloud function.

logoutUser function

Update the logoutUser function with the following code:

const logoutUser = async () => {
    await account.deleteSession("current");
    setUser(null);
};
Enter fullscreen mode Exit fullscreen mode

The logoutUser function logs a user out using deleteSession method of the account object.

checkUserStatus function

Update the checkUserStatus with the following code:

 const checkUserStatus = async () => {
    try {
      const user = await account.get();
      setUser(user);
    } catch (error) {
      console.error(error);
    }

    setLoading(false);
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we're retrieving and setting the user.

Next, we are going to implement the file upload and sharing feature.

Building the File Upload and Sharing Flow

Implementing the file upload functionality

When a user uploads a file, we store the file in Appwrite Storage and the file metadata (shared_with, fileId, ownerId) to the Appwrite database. Also, we create an instance of the file resource in Permit.io.

In the main.js file of our Appwrite function, add the following code:

if (triggerType === 'event' && data.bucketId) {
    try {
      const user = await users.get(headers['x-appwrite-user-id']);
      const resourceInstance = await permit.api.resourceInstances.create({
        resource: 'file',
        key: data.$id,
        tenant: 'default',
      });
      const resource_instance = `file:${data.$id}`;
      const assignedRoleToUser = await permit.api.roleAssignments.assign({
        user: user?.email,
        role: 'owner',
        resource_instance,
      });
      log(assignedRoleToUser, resourceInstance);
      return res.json({
        ok: true,
        message: `Resource instance created and the owner is ${user?.name}`,
      });
    } catch (err) {
      error(err);
      return res.json({
        ok: false,
        message: `There was error adding file resource`,
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

In the code above, we’re checking if the trigger type is an event and that the data returned has a bucketId field (so that we know that event is that of a file upload).

What’s going on behind the scenes?

Like I said, the headers object contains important information including the calling user’s ID through the x-appwrite-user-id property. We will combine the value of the x-appwrite-user-id and the users object to get the calling user’s email.

Since the data object contains details of the uploaded file, we created a resource instance in our Permit dashboard using the file ID and the calling user’s email.

Finally, in the FileUploadPage.tsx file, update the handleUpload function with the following code:

async function handleUpload() {
    if (file) {
      setStatus("uploading");
      setIsLoading(true);
      const fileData = new FormData();
      fileData.append("file", file);

      try {
        const fileId = ID.unique();

        // store file in Appwrite Storage
        const createdFile = await storage.createFile(
          import.meta.env.VITE_FILES_BUCKET_ID,
          fileId,
          file
        );

        // store metadata in database
        const fileMetadata = await database.createDocument(
          import.meta.env.VITE_DATABASE_ID,
          import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
          fileId,
          {
            fileName: file.name,
            fileId: createdFile.$id,
            ownerId: user.$id,
            shared_with: [],
          }
        );
        toast.success("File uploaded successfully");
        setStatus("success");
        setIsLoading(false);
      } catch (error) {
        console.log(error);
        if (error instanceof Error) toast.error(error.message);
        setStatus("fail");
      } finally {
        setIsLoading(false);
        setStatus("initial");
        setFile(null);
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

The handleUpload function handles the file upload process. First, it checks if a file is selected and sets the upload status to "uploading" while loading is enabled. The file is appended to a FormData object, and then:

  • the file is uploaded to Appwrite's storage using createFile, generating a unique file ID.

  • The file metadata (fileName, ownerId, shared_with, and fileId) is stored in the Appwrite database using createDocument.

  • A resource instance is created in Permit.io automatically through the Appwrite cloud function.

If the upload is successful, the status is set to "success", and a success toast is shown. In case of failure, it shows an error toast and sets the status to "fail". The function resets the loading state and the file once the process is complete.

Displaying files associated with a user

When the file upload page loads, we want to fetch files associated with the current user, i.e. files owned by the user and files shared with the user by other users.

To implement this, add the following code to the configurations/appwrite.ts file:

import { Client, Account, Databases, Storage, Query, Models, Functions } from "appwrite";

export interface FunctionPromiseReturnType extends Models.Execution {
  ok?: boolean;
  message?: string;
  role?: string | string[];
  permitted?: boolean;
}

export interface Document extends Models.Document {
  ownerId: string;
  fileId: string;
  fileName: string;
  shared_with: string[];
}

export const client = new Client()
  .setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
  .setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID as string);
Enter fullscreen mode Exit fullscreen mode

In this part of the file, we’re importing the necessary Appwrite modules and defining two TypeScript interfaces for function execution responses and document metadata. Additionally, we initialized the Appwrite client with the endpoint and project ID from environment variables.

export const account = new Account(client);
export const database = new Databases(client);
export const storage = new Storage(client);
export const functions = new Functions(client);
Enter fullscreen mode Exit fullscreen mode

Here, we create and export instances for managing user accounts, databases, storage, and functions. These instances enable interaction with various Appwrite services throughout your application.

// Fetch metadata for owned and shared files
export const fetchMetadata = async (userId: string, userEmail: string) => {
  try {
    const ownedFilesMetadata = await database.listDocuments(
      import.meta.env.VITE_DATABASE_ID,
      import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
      [Query.equal("ownerId", userId)]
    );
    const sharedFilesMetadata = await database.listDocuments(
      import.meta.env.VITE_DATABASE_ID,
      import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
      [Query.contains("shared_with", [userEmail])]
    );
    return {
      ownedFilesMetadata: ownedFilesMetadata.documents,
      sharedFilesMetadata: sharedFilesMetadata.documents,
    };
  } catch (error) {
    console.log(error);
    return { ownFilesMetadata: [], sharedFilesMetadata: [] };
  }
};

// Fetch file URL from storage
export const fetchFileFromStorage = async (fileId: string) => {
  try {
    return storage.getFileView(import.meta.env.VITE_DATABASE_ID, fileId);
  } catch (error) {
    console.log(error);
    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode

This part of the code contains two functions:

  • fetchMetadata queries the database to get files owned by or shared with a user, while

  • fetchFileFromStorage retrieves a file’s URL from storage. Both functions include basic error handling.

export const fetchFilesWithUrl = async (files: Document[]) => {
  return Promise.all(
    files.map(async (file) => {
      try {
        const fileUrl = await fetchFileFromStorage(file.fileId);
        return { ...file, fileUrl };
      } catch (error) {
        console.log(error);
        return file;
      }
    })
  );
};

export async function fetchFilesWithUserPermission(userId: string, userEmail: string) {
  const { ownedFilesMetadata, sharedFilesMetadata } = await fetchMetadata(userId, userEmail);
  const ownedFilesWithUrl = await fetchFilesWithUrl(ownedFilesMetadata);
  const sharedFilesWithUrl = await fetchFilesWithUrl(sharedFilesMetadata);

  const processFiles = async (files: Document[]) => {
    return Promise.all(
      files.map(async (file) => {
        const body = {
          endpoint: "check-user-permission",
          userKey: userEmail,
          fileId: file.fileId,
          action: "share"
        };
        const execution = await functions.createExecution(import.meta.env.VITE_FUNCTION_ID, JSON.stringify(body));
        const parsedExecution = JSON.parse(execution.responseBody);
        return { ...file, canShare: parsedExecution?.permitted };
      })
    );
  };

  const ownedFilesWithRole = await processFiles(ownedFilesWithUrl);
  const sharedFilesWithRole = await processFiles(sharedFilesWithUrl);

  return [...ownedFilesWithRole, ...sharedFilesWithRole];
}
Enter fullscreen mode Exit fullscreen mode

In this final snippet, we created two functions:

  • fetchFilesWithUrl attaches a file URL to each document, while

  • fetchFilesWithUserPermission gathers metadata, augments files with their URLs, and checks user permissions for sharing each file by calling a backend function. Error handling is performed using try...catch blocks.

In the main.js of our Appwrite function, add the following code:

if (triggerType === 'http' && data.endpoint === 'get-user-role') {
    const { userKey, fileId } = data;
    const resourceInstance = `file:${fileId}`;
    try {
      const role = await permit.getUserPermissions(
        userKey,
        ['default'],
        [resourceInstance]
      );
      log(role);
      return res.json({ ok: true, role: role[`file:${fileId}`]?.roles });
    } catch (err) {
      error(err);
      return res.json({
        ok: false,
        message: `An error occurred while fetching user role: ${err instanceof Error ? err.message : 'An unexpected error occurred'}`,
      });
    }
  }

  if (triggerType === 'http' && data.endpoint === 'check-user-permission') {
    const { userKey, fileId, action } = data;
    const resourceInstance = `file:${fileId}`;
    try {
      const permitted = await permit.check(userKey, action, resourceInstance);
      log(permitted);
      return res.json({ ok: true, permitted });
    } catch (err) {
      error(err);
      return res.json({
        ok: false,
        message: `${err instanceof Error ? err.message : 'An unknown error occurred'}`,
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

In the code above, we’re checking if the trigger type is a HTTP method (function.createExecution uses HTTP to execute an Appwrite function). Also, we’re extracting the necessary data from the data object (provided when we called the cloud function in the appwrite.ts file using function.createExecution) and getting a user’s role for a particular file and checking for a user’s permission to share a file respectively.

Let's create a FileList component for displaying each file:

import { Download, FileText, Share2 } from "lucide-react";
import { useRef, useState } from "react";
import ShareModal from "./ShareModal";
import { storage } from "../configurations/appwrite";

interface FileListProps {
  fileName: string;
  isOwner?: boolean;
  fileId: string;
}
Enter fullscreen mode Exit fullscreen mode

We’re importing necessary icons, hooks, and components. We also define the FileListProps interface to specify the expected props for the component, including the file name, file ID, and an optional ownership flag.

function FileList({ fileName, fileId }: FileListProps) {
  const dialogRef = useRef<HTMLDialogElement | null>(null);
  const [fileID, setFileID] = useState(fileId);

  // Opens the share modal and locks page scrolling  
  const handleShareFile = () => {
    setFileID(fileId);
    dialogRef.current?.showModal();
    document.body.style.overflow = "hidden";
    console.log(fileID);
  };

  // Initiates file download by redirecting to the download URL  
  const handleDownloadFile = () => {
    const result = storage.getFileDownload(
      import.meta.env.VITE_FILES_BUCKET_ID,
      fileID
    );
    window.location.href = result;
  };

  const closeDialog = (): void => {
    dialogRef.current?.close();
    document.body.style.overflow = "visible";
  };
Enter fullscreen mode Exit fullscreen mode

We initialize a ref for the share modal and a state variable for the file ID. Event handlers are included for sharing and downloading files, as well as a function to close the modal. Each handler performs a specific action: opening the modal, triggering a download, or resetting page scroll.

  return (
    <div>
      <FileText size={100} />
      <h3>{fileName}</h3>
      <div>
        <button onClick={handleShareFile}>
          Share <Share2 size={20} />
        </button>
        <button onClick={handleDownloadFile}>
          Download <Download size={20} />
        </button>
      </div>
      <ShareModal
        fileId={fileID}
        dialogRef={dialogRef}
        closeDialog={closeDialog}
      />
    </div>
  );
}

export default FileList;
Enter fullscreen mode Exit fullscreen mode

We’re rendering the UI for the file list. It displays the file icon and name, along with buttons for sharing and downloading.

The ShareModal component is used to share the file. The fileID, dialogRef, and closeDialog, as props to the ShareModal component.

We'll create the ShareModal component when we build the sharing feature.

Here's a demo of the file upload feature:

demo1

Building the file sharing feature

This is the final stage of our application. In this section, we're going to build the sharing feature.

When the user clicks on the "Share" button, a modal pops up with a form of two fields where the user puts in the email of the person to share the file with and selects the role to assign to such person for that specific file.

First, let's create the ShareModal component.

import React, { RefObject, useState } from "react";
import { shareFile } from "../actions/actions";
import { toast } from "react-toastify";

interface ShareModalProps {
  dialogRef: RefObject<HTMLDialogElement>;
  fileId: string;
  closeDialog: () => void;
}

function ShareModal({ fileId, closeDialog, dialogRef }: ShareModalProps) {
  const [email, setEmail] = useState("");
  const [role, setRole] = useState("viewer");
  // Further code in subsequent snippets...
Enter fullscreen mode Exit fullscreen mode

We’re setting up the component by importing necessary modules, defining the prop types, and initializing state for managing the email and role values.

  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const handleRoleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setRole(e.target.value);
  };

  async function handleFileShare(e: React.ChangeEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!email) return;
    try {
      const res = await shareFile(fileId, email, role);
      if (!res.success) throw new Error(res.message);
      toast.success(`File shared successfully with ${email}`);
      closeDialog();
    } catch (error) {
      if (error instanceof Error) toast.error(error.message);
      closeDialog();
    } finally {
      setEmail(""); setRole("viewer");
    }
  }
Enter fullscreen mode Exit fullscreen mode

In the code above, we defined the event handlers for updating state on input changes and handling the form submission. The submission logic performs validation, calls the shareFile function, and provides user feedback with notifications.

  return (
    <dialog ref={dialogRef} className="h-1/3 w-1/3 py-3 px-2">
      <div className="text-right">
        <button onClick={closeDialog} className="bg-gray-500 px-3 py-1 rounded hover:bg-red-500">
          X
        </button>
      </div>
      <form onSubmit={handleFileShare} className="flex flex-col gap-4 px-8 py-4">
        <div>
          <label htmlFor="email">Email:</label>
          <input type="text" id="email" value={email} onChange={handleEmailChange} className="border rounded w-4/6 py-1 px-3" />
        </div>
        <div>
          <label htmlFor="select-role">Role:</label>
          <select id="select-role" value={role} onChange={handleRoleChange} className="border rounded py-1 px-1">
            <option value="owner">owner</option>
            <option value="viewer">viewer</option>
          </select>
        </div>
        <div className="flex gap-8">
          <button type="submit" className="bg-gray-200 hover:bg-gray-400 py-2 px-4 rounded">Share</button>
          <button onClick={closeDialog} className="bg-red-500 hover:bg-red-700 py-2 px-4 rounded">Cancel</button>
        </div>
      </form>
    </dialog>
  );
}

export default ShareModal;
Enter fullscreen mode Exit fullscreen mode

In the code above, we’re returning the modal’s JSX which includes:

  • the dialog container,

  • a close button,

  • and a form with inputs for email and role, along with submit and cancel buttons.

Now, let's create the shareFile function.

However, to create the file sharing functionality, we need to first update our Appwrite function so that it updates the role of the user who the file is being shared with accordingly.

In the main.js file of our Appwrite function, add the following code:

if (triggerType === 'http' && data.endpoint === 'update-user-role') {
    const { sharedUserKey, role, fileId, requesterEmail } = data;
    const resourceInstance = `file:${fileId}`;
    try {
      const isPermitted = await permit.check(
        requesterEmail,
        'share',
        resourceInstance
      );
      if (!isPermitted)
        return res.json({
          ok: false,
          message: "You don't have permission to share this file",
        });
      const roleAssignment = await permit.api.roleAssignments.assign({
        user: sharedUserKey,
        role,
        resource_instance: resourceInstance,
      });
      log(roleAssignment);
      return res.empty();
    } catch (err) {
      error(err);
      return res.json({
        ok: false,
        message: `${err instanceof Error ? err.message : 'An unidentified error occurred'}`,
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

When a user attempts to share a file with another user, the permit.check() function is crucial. Here, we are not just checking if the user has a general 'share' permission, but if they have the 'share' permission based on their relationship (in this case, implicitly 'owner') to the specific file resource instance identified by resourceInstance.

Creating the function for sharing files

The next step after our permissions evaluation process in the cloud function addresses the shareFile function's execution for practical file sharing capabilities. The function implements the sharing rules while maintaining secure file processing and efficient handling of all file share requests.

import { account, database, Document, FunctionPromiseReturnType } from "../configurations/appwrite";
import { functions } from "../configurations/appwrite";
Enter fullscreen mode Exit fullscreen mode

The necessary modules together with types come from our Appwrite configuration file: configurations/appwrite.ts. Throughout this fragment we utilize account to handle user management and database to access the database and functions to run our cloud function. Document and FunctionPromiseReturnType serve as types that provide type safety when operating with Appwrite responses.

export async function shareFile(
  fileId: string,
  userEmail: string,
  role: string
) {
  const user = await account.get();
Enter fullscreen mode Exit fullscreen mode

The shareFile function is defined to accept a file ID, a user's email, and a role as parameters. The first operation within the function is retrieving the current user information using account.get(). This is crucial to validate the user's identity before proceeding with the file sharing process.

  try {
    const currentDoc: Document = await database.getDocument(
      import.meta.env.VITE_DATABASE_ID,
      import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
      fileId
    );
Enter fullscreen mode Exit fullscreen mode

Inside the try block, the function fetches the file metadata from the database using database.getDocument(). The document contains essential details like the file owner and the list of users with whom the file has been shared with.

    if(!user) throw new Error("Unauthorized: User not found");

    if(currentDoc.shared_with.includes(userEmail)) throw new Error("Error: User already has access to this file");

    if(currentDoc.ownerId === user.$id && userEmail === user.email) throw new Error("Error: You can't share a file with yourself");
Enter fullscreen mode Exit fullscreen mode

Before proceeding, the function performs several validations:

  • User Existence: It checks whether a user was successfully retrieved.

  • Duplicate Sharing: It ensures the file isn’t already shared with the given email.

  • Self-sharing: It prevents the owner from sharing the file with themselves.

These checks prevent unnecessary function calls and potential misuse.

    const data = {
      sharedUserKey: userEmail,
      role,
      fileId,
      requesterEmail: user?.email,
      endpoint: "update-user-role"
    }

    const updateRoleResult: FunctionPromiseReturnType = await functions.createExecution(import.meta.env.VITE_FUNCTION_ID, JSON.stringify(data));
Enter fullscreen mode Exit fullscreen mode

Here, a data object is prepared with all necessary details to update the user role on the file. The functions.createExecution() method is then called, passing the function ID and the data (converted to a JSON string). This executes our cloud function which handles the role update logic.

    if (updateRoleResult?.ok === false && updateRoleResult?.message === "You don't have permission to share this file") {
      throw new Error("Unauthorized: You don't have permission to update user role");
    }

    if (updateRoleResult?.ok === false) throw new Error("An unknown error occurred");
Enter fullscreen mode Exit fullscreen mode

After executing the role update function, the code checks if the execution was successful:

  • If the function indicates a permission error, an appropriate error is thrown.

  • A generic error is thrown if the update fails for other reasons.

This ensures that any failure in the role update process is properly communicated back to the caller.

    const result = await database.updateDocument(
      import.meta.env.VITE_DATABASE_ID,
      import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
      fileId,
      {
        shared_with: [...currentDoc.shared_with, userEmail],
      }
    );
Enter fullscreen mode Exit fullscreen mode

If the role update is successful, the file metadata is updated to include the new user email in the shared_with array. This is done using the database.updateDocument() method.

    return {
      success: true,
      message: updateRoleResult?.message,
    };
  } catch (error) {
    if (error instanceof Error) {
      console.error(error.message);
      return {
        success: false,
        message: error.message,
      }
    };
    return {
      success: false,
      message: "An error unknown occurred while sharing the file",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, the function returns a success response containing the message from the role update function. If any error is encountered during the process, the catch block:

  • captures it,

  • logs the error message, and

  • returns an error response with the relevant message.

This ensures robust error handling and clear communication of issues.

With the shareFile function fully defined, add it to your actions/action.ts file. Now, users can easily share files with one another.

Handling file download

Add the following code to the FileList.tsx file to handle file downloads:

const handleDownloadFile = () => {
    const result = storage.getFileDownload(
      import.meta.env.VITE_FILES_BUCKET_ID,
      fileID
    );
    window.location.href = result;
  };
Enter fullscreen mode Exit fullscreen mode

Deploying our Appwrite function

We need to deploy our function so that our React application can invoke it.

Use the following command to deploy our Appwrite cloud function:

appwrite push function
Enter fullscreen mode Exit fullscreen mode

Here’s the Demo of the application:

demo2

when there’s an error sharing a file

error-img

when a user shares a file successfully

Create other users and share files with them. Log in with those users’ credentials and you’ll see the files shared with them.

Congratulations! You've built a file-sharing web application using Permit.io, Appwrite and React.

Fixing Appwrite function not accessing your Permit PDP

Since our PDP is running locally, our deployed Appwrite function doesn’t have access to it and therefore, will cause the function execution to fail.

To fix this, we have to make our PDP publicly accessible. And we’re going to do that by using Ngrok.

Ngrok is a cross-platform application that creates secure tunnels (paths) to localhost machine. It enables developers to expose a local development server to the Internet with minimal effort.

Steps to expose our PDP using Ngrok

Install Ngrok (if you haven’t already)

  • Download it from ngrok.com and follow the installation steps for your OS.

  • Authenticate using ngrok authtoken <your-ngrok-token> (find your token in your ngrok dashboard).

Expose Your Permit PDP Container

  • Assuming your Permit PDP container is running on port 7766, use the following command to expose it:
ngrok http http:localhost:7766
Enter fullscreen mode Exit fullscreen mode

Make sure your Docker container is running.

This will generate a public URL like: “https://d143-105-116-13-106.ngrok-free.app”.

Update your PDP in the Appwrite function:

const permit = new Permit({
  token: process.env.PERMIT_API_KEY_PROD,
  pdp: '<https://d143-105-116-13-106.ngrok-free.app>',
});
Enter fullscreen mode Exit fullscreen mode

And deploy again using:

appwrite push function
Enter fullscreen mode Exit fullscreen mode

Now, our Appwrite function has access to our Permit PDP and should execute successfully.

Troubleshooting

Make sure your Docker container PDP and Node/Express server is running. Else, you will not be able to interact with Permit.io.

Additionally, if you have issues deploying your Appwrite function, update your appwrite.json file by adding the following fields:

"cpu": 1,
"memory": 512,
Enter fullscreen mode Exit fullscreen mode

And remove the specification field.

Conclusion

In this tutorial, you learned how to build a file-sharing application using the power of Relationship-Based Access Control (ReBAC). We've shown how ReBAC allows for fine-grained control over file access by defining permissions based on the relationships between users and individual files, offering a more flexible and secure authorization model compared to traditional role-based approaches.


Resources


Thankyou for reading! If you found this article useful, share it with your peers and community.

If You ❤️ My Content! Connect Me on Twitter

Check SaaS Tools I Use 👉🏼Access here!

I am open to collaborating on Blog Articles and Guest Posts🫱🏼‍🫲🏼 📅Contact Here

Top comments (8)

Collapse
 
arindam_1729 profile image
Arindam Majumder

Great One 🔥

Collapse
 
astrodevil profile image
Astrodevil

Thankyou :)

Collapse
 
nevodavid profile image
Nevo David

Dang, setting up file sharing with all those checks feels like building a super safe treehouse with a secret password and a lock for each buddy who visits but it's pretty good

Collapse
 
astrodevil profile image
Astrodevil

because why not, thanks Nevo!

Collapse
 
xtitan profile image
Abhinav

great.

Collapse
 
astrodevil profile image
Astrodevil

thanks

Collapse
 
sahan_2af37cf12f7b3f0e7ff profile image
sahan

hi

Collapse
 
astrodevil profile image
Astrodevil

hey?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.