Skip to content

Permission System (RBAC)

DBackup implements Role-Based Access Control (RBAC) to control feature access through user groups with defined permissions.

Architecture

┌─────────────┐
│    User     │
│             │
│  groupId ───┼──────────────┐
└─────────────┘              │

                    ┌─────────────────┐
                    │      Group      │
                    │                 │
                    │  permissions[]  │
                    │  - users:read   │
                    │  - jobs:write   │
                    │  - ...          │
                    └─────────────────┘

Key Concepts:

  • Permissions: Granular strings (e.g., sources:read, jobs:execute)
  • Groups: Contain a list of permissions
  • Users: Assigned to exactly one group (or none)
  • No Group = No Access: Users without a group have no permissions

Database Schema

prisma
model User {
  id        String   @id  // Set by auth system
  name      String
  email     String   @unique
  // ...
  groupId   String?
  group     Group?   @relation(fields: [groupId], references: [id])
}

model Group {
  id          String   @id @default(uuid())
  name        String   @unique
  permissions String   // JSON array: ["users:read", "jobs:write"]
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  users       User[]
}

Permission Definitions

All permissions are defined in src/lib/permissions.ts:

typescript
export const PERMISSIONS = {
  // User Management
  USERS: {
    READ: "users:read",
    WRITE: "users:write",
  },

  // Group Management
  GROUPS: {
    READ: "groups:read",
    WRITE: "groups:write",
  },

  // Database Sources
  SOURCES: {
    READ: "sources:read",
    WRITE: "sources:write",
  },

  // Storage Destinations
  DESTINATIONS: {
    READ: "destinations:read",
    WRITE: "destinations:write",
  },

  // Backup Jobs
  JOBS: {
    READ: "jobs:read",
    WRITE: "jobs:write",
    EXECUTE: "jobs:execute",
  },

  // Storage Explorer
  STORAGE: {
    READ: "storage:read",
    DOWNLOAD: "storage:download",
    RESTORE: "storage:restore",
    DELETE: "storage:delete",
  },

  // Execution History
  HISTORY: {
    READ: "history:read",
  },

  // Notifications
  NOTIFICATIONS: {
    READ: "notifications:read",
    WRITE: "notifications:write",
  },

  // Encryption Vault
  VAULT: {
    READ: "vault:read",
    WRITE: "vault:write",
  },

  // System Settings
  SETTINGS: {
    READ: "settings:read",
    WRITE: "settings:write",
  },

  // Profile Settings
  PROFILE: {
    UPDATE_NAME: "profile:update_name",
    UPDATE_EMAIL: "profile:update_email",
    UPDATE_PASSWORD: "profile:update_password",
    MANAGE_2FA: "profile:manage_2fa",
    MANAGE_PASSKEYS: "profile:manage_passkeys",
  },

  // Audit Logs
  AUDIT: {
    READ: "audit:read",
  },

  // API Keys
  API_KEYS: {
    READ: "api-keys:read",
    WRITE: "api-keys:write",
  },
} as const;

export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS][keyof typeof PERMISSIONS[keyof typeof PERMISSIONS]];

Access Control Functions

Located in src/lib/access-control.ts:

checkPermission()

Throws an error if the user lacks permission:

typescript
export async function checkPermission(permission: Permission): Promise<void> {
  const session = await auth.getSession();

  if (!session?.user) {
    throw new Error("Not authenticated");
  }

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    include: { group: true },
  });

  if (!user?.group) {
    throw new Error("No group assigned");
  }

  const permissions = JSON.parse(user.group.permissions) as string[];

  if (!permissions.includes(permission)) {
    throw new Error(`Missing permission: ${permission}`);
  }
}

hasPermission()

Returns boolean (non-throwing):

typescript
export async function hasPermission(permission: Permission): Promise<boolean> {
  try {
    await checkPermission(permission);
    return true;
  } catch {
    return false;
  }
}

getUserPermissions()

Returns all user permissions:

typescript
export async function getUserPermissions(): Promise<string[]> {
  const session = await auth.getSession();

  if (!session?.user) return [];

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    include: { group: true },
  });

  if (!user?.group) return [];

  return JSON.parse(user.group.permissions);
}

Implementation Guide

Protecting Server Actions

Every data-modifying Server Action MUST check permissions:

typescript
// src/app/actions/source.ts
"use server";

import { checkPermission } from "@/lib/access-control";
import { PERMISSIONS } from "@/lib/permissions";

export async function createSource(data: SourceInput) {
  // 1. Permission check (throws if denied)
  await checkPermission(PERMISSIONS.SOURCES.WRITE);

  // 2. Validation
  const validated = SourceSchema.parse(data);

  // 3. Business logic
  return SourceService.create(validated);
}

export async function deleteSource(id: string) {
  await checkPermission(PERMISSIONS.SOURCES.WRITE);
  return SourceService.delete(id);
}

export async function getSources() {
  await checkPermission(PERMISSIONS.SOURCES.READ);
  return SourceService.getAll();
}

Protecting Page Access

typescript
// src/app/dashboard/sources/page.tsx
import { redirect } from "next/navigation";
import { hasPermission } from "@/lib/access-control";
import { PERMISSIONS } from "@/lib/permissions";

export default async function SourcesPage() {
  // Redirect if no access
  if (!await hasPermission(PERMISSIONS.SOURCES.READ)) {
    redirect("/dashboard/unauthorized");
  }

  // Fetch data and render
  const sources = await getSources();
  return <SourceList sources={sources} />;
}

Conditional UI Rendering

Pass permission flags from Server Components to Client Components:

typescript
// Server Component (Page)
// src/app/dashboard/sources/page.tsx
import { getUserPermissions } from "@/lib/access-control";
import { PERMISSIONS } from "@/lib/permissions";
import { SourceManager } from "@/components/source-manager";

export default async function SourcesPage() {
  const permissions = await getUserPermissions();

  return (
    <SourceManager
      canCreate={permissions.includes(PERMISSIONS.SOURCES.WRITE)}
      canDelete={permissions.includes(PERMISSIONS.SOURCES.WRITE)}
    />
  );
}
typescript
// Client Component
// src/components/source-manager.tsx
"use client";

interface Props {
  canCreate: boolean;
  canDelete: boolean;
}

export function SourceManager({ canCreate, canDelete }: Props) {
  return (
    <div>
      {canCreate && (
        <Button onClick={() => setShowCreateDialog(true)}>
          Add Source
        </Button>
      )}

      <SourceList
        onDelete={canDelete ? handleDelete : undefined}
      />
    </div>
  );
}

Permission Categories

Resource Management

PermissionDescription
sources:readView database sources
sources:writeCreate, edit, delete sources
destinations:readView storage destinations
destinations:writeCreate, edit, delete destinations
notifications:readView notification configs
notifications:writeCreate, edit, delete notifications

Backup Operations

PermissionDescription
jobs:readView backup jobs
jobs:writeCreate, edit, delete jobs
jobs:executeManually trigger backups
history:readView execution history

Storage & Recovery

PermissionDescription
storage:readBrowse Storage Explorer
storage:downloadDownload backup files
storage:restoreTrigger database restores
storage:deleteDelete backup files

Administration

PermissionDescription
users:readView user list
users:writeCreate, edit, delete users
groups:readView groups
groups:writeCreate, edit, delete groups
settings:readView system settings
settings:writeModify system settings
vault:readView encryption profiles
vault:writeCreate, delete encryption profiles
audit:readView audit logs

Profile (Self-Service)

PermissionDescription
profile:update_nameChange own display name
profile:update_emailChange own email
profile:update_passwordChange own password
profile:manage_2faEnable/disable 2FA
profile:manage_passkeysManage passkeys

Default Groups

Recommended group templates:

Admin

json
["users:read", "users:write", "groups:read", "groups:write",
 "sources:read", "sources:write", "destinations:read", "destinations:write",
 "jobs:read", "jobs:write", "jobs:execute", "history:read",
 "storage:read", "storage:download", "storage:restore", "storage:delete",
 "notifications:read", "notifications:write", "vault:read", "vault:write",
 "settings:read", "settings:write", "audit:read"]

Operator

json
["sources:read", "destinations:read", "jobs:read", "jobs:execute",
 "history:read", "storage:read", "storage:download", "storage:restore"]

Viewer

json
["sources:read", "destinations:read", "jobs:read", "history:read",
 "storage:read"]

Audit Trail

Permission changes are logged:

typescript
// Log group permission changes
await prisma.auditLog.create({
  data: {
    userId: currentUser.id,
    action: "GROUP_UPDATE",
    targetType: "Group",
    targetId: group.id,
    details: JSON.stringify({
      oldPermissions,
      newPermissions,
    }),
  },
});

Testing

typescript
// tests/unit/access-control.test.ts
describe("Access Control", () => {
  it("denies access without group", async () => {
    mockSession({ user: { groupId: null } });

    await expect(
      checkPermission(PERMISSIONS.SOURCES.READ)
    ).rejects.toThrow("No group assigned");
  });

  it("denies missing permission", async () => {
    mockSession({
      user: {
        group: { permissions: '["users:read"]' },
      },
    });

    await expect(
      checkPermission(PERMISSIONS.SOURCES.WRITE)
    ).rejects.toThrow("Missing permission");
  });

  it("allows valid permission", async () => {
    mockSession({
      user: {
        group: { permissions: '["sources:read"]' },
      },
    });

    await expect(
      checkPermission(PERMISSIONS.SOURCES.READ)
    ).resolves.not.toThrow();
  });
});

Released under the GNU General Public License. | Privacy · Legal Notice