Skip to content

Permissions and Relationships

storage permissions relationships Hasura GraphQL communities multi-tenant guide

This guide walks through two common file-access patterns: personal files (users access only their own) and community files (shared across a group via a junction table and membership). Both patterns build on Hasura permissions applied to the storage.files table.

The simplest pattern — users can upload, view, and delete only their own files in a dedicated bucket.

Insert a personal bucket into storage.buckets (via the Dashboard or a migration):

INSERT INTO storage.buckets (id)
VALUES ('personal');

Set permissions on the storage.files table for the user role:

SettingValue
Columnsid, bucket_id, name, size, mime_type
Row check{"bucket_id": {"_eq": "personal"}}
Column presetuploaded_by_user_id = X-Hasura-User-Id

The column preset automatically tags every upload with the authenticated user’s ID.

// Upload a file to the personal bucket
const { body } = await nhost.storage.uploadFiles({
'bucket-id': 'personal',
'file[]': [file],
})
const uploadedFile = body.processedFiles?.[0]

Because metadata lives in Hasura, you can query files via GraphQL — permissions are applied automatically:

const response = await nhost.graphql.request<{
files: Array<{ id: string; name: string; size: number; mimeType: string; createdAt: string }>
}>({
query: `query MyFiles {
files(where: { bucketId: { _eq: "personal" } }, order_by: { createdAt: desc }) {
id
name
size
mimeType
createdAt
}
}`,
})
// Only returns files uploaded by the current user (enforced by permissions)
const files = response.body.data?.files ?? []

A more advanced pattern where files are shared with groups. Users join communities, upload files to a shared communities storage bucket, and associate files with communities through a junction table. Access is controlled by membership — only community members can see the community’s files.

erDiagram
communities ||--o{ community_members : has
communities ||--o{ community_files : has
community_files }o--|| storage_files : references
community_members }o--|| auth_users : belongs_to
storage_files }o--|| auth_users : uploaded_by
communities {
uuid id PK
text name
text description
}
community_members {
uuid id PK
uuid user_id FK
uuid community_id FK
timestamptz joined_at
}
community_files {
uuid id PK
uuid file_id FK
uuid community_id FK
}
storage_files {
uuid id PK
text name
uuid bucket_id FK
uuid uploaded_by_user_id FK
}
auth_users {
uuid id PK
text display_name
text email
}
-- Communities bucket
INSERT INTO storage.buckets (id) VALUES ('communities');
-- Communities
CREATE TABLE public.communities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Membership junction
CREATE TABLE public.community_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, community_id)
);
-- File ↔ community junction
CREATE TABLE public.community_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
community_id UUID NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Track all three tables and their foreign-key relationships in Hasura so they appear in the GraphQL API.

Source tableRelationshipTypeTarget
communitiesmembersarraycommunity_members.community_id
communitiescommunity_filesarraycommunity_files.community_id
community_memberscommunityobjectcommunity_members.community_id → communities.id
community_membersuserobjectcommunity_members.user_id → auth.users.id
community_filescommunityobjectcommunity_files.community_id → communities.id
community_filesfileobjectcommunity_files.file_id → storage.files.id
storage.filescommunity_filesarraycommunity_files.file_id
storage.filesuploadedByUserobjectstorage.files.uploaded_by_user_id → auth.users.id (manual config)
OperationColumnsFilter
Selectall{} (all communities visible)
SettingValue
Columnsall
Filter{}

Only members of the file’s community can see it:

SettingValue
Columnsall
Filter{"community": {"members": {"user_id": {"_eq": "X-Hasura-User-Id"}}}}

This traverses the communitymembers relationships to check membership.

Update the existing storage.files permissions to support all three buckets:

SettingValue
Columnsid, bucket_id, name, size, mime_type
Row check{"bucket_id": {"_in": ["default", "personal", "communities"]}}
Column presetuploaded_by_user_id = X-Hasura-User-Id
// Join a community
await nhost.graphql.request({
query: `mutation JoinCommunity($communityId: uuid!) {
insert_community_members_one(object: { community_id: $communityId }) {
id
}
}`,
variables: { communityId },
})
// Leave a community
await nhost.graphql.request({
query: `mutation LeaveCommunity($communityId: uuid!, $userId: uuid!) {
delete_community_members(where: {
community_id: { _eq: $communityId },
user_id: { _eq: $userId }
}) {
affected_rows
}
}`,
variables: { communityId, userId },
})

The insert permission’s column preset automatically sets user_id to the authenticated user, and the check constraint prevents users from adding memberships for other users.

// 1. Upload the file to the communities bucket
const uploadResponse = await nhost.storage.uploadFiles({
'bucket-id': 'communities',
'file[]': [file],
})
const uploadedFile = uploadResponse.body.processedFiles?.[0]
if (!uploadedFile?.id) throw new Error('Upload failed')
// 2. Associate the file with a community
await nhost.graphql.request({
query: `mutation AddCommunityFile($fileId: uuid!, $communityId: uuid!) {
insert_community_files_one(object: {
file_id: $fileId,
community_id: $communityId
}) {
id
}
}`,
variables: { fileId: uploadedFile.id, communityId },
})
const response = await nhost.graphql.request<{
community_files: Array<{
id: string
file: { id: string; name: string; size: number; mimeType: string; uploadedByUser: { displayName: string } | null }
}>
}>({
query: `query GetCommunityFiles($communityId: uuid!) {
community_files(where: { community_id: { _eq: $communityId } }) {
id
file {
id
name
size
mimeType
uploadedByUser {
displayName
}
}
}
}`,
variables: { communityId },
})
// Permissions automatically filter to communities the user is a member of
const communityFiles = response.body.data?.community_files ?? []
// Remove the junction record
await nhost.graphql.request({
query: `mutation RemoveCommunityFile($id: uuid!) {
delete_community_files_by_pk(id: $id) {
id
}
}`,
variables: { id: communityFileId },
})
// Optionally delete the file from storage entirely
await nhost.storage.deleteFile(fileId)

The ON DELETE CASCADE on community_files.file_id automatically removes all community associations when the storage file is deleted.