Phase 3 Completed

This commit is contained in:
Rayyan
2026-08-02 17:44:50 +07:00
parent 7ce0de4e91
commit ffc5ecd259
68 changed files with 3132 additions and 163 deletions
+112
View File
@@ -0,0 +1,112 @@
-- Initial migration for RayLab Core (PostgreSQL)
-- Requires: CREATE EXTENSION IF NOT EXISTS pgcrypto; (for gen_random_uuid())
BEGIN;
-- Extension for UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Enum types
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'userrolesource') THEN
CREATE TYPE "UserRoleSource" AS ENUM ('AUTHENTIK', 'SYSTEM');
END IF;
END $$;
-- Users
CREATE TABLE IF NOT EXISTS "User" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_id text UNIQUE,
authentik_user_id text UNIQUE,
authentik_subject text UNIQUE,
username varchar(255) UNIQUE,
email varchar(320) UNIQUE,
name varchar(255),
picture text,
is_active boolean NOT NULL DEFAULT true,
deleted_at timestamptz,
last_seen_at timestamptz,
last_synced_at timestamptz,
sync_status text,
storage_quota bigint DEFAULT 10737418240,
storage_used bigint DEFAULT 0,
metadata jsonb,
last_group_hash text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_authentik_id ON "User" (authentik_id);
CREATE INDEX IF NOT EXISTS idx_user_username ON "User" (username);
CREATE INDEX IF NOT EXISTS idx_user_email ON "User" (email);
-- Roles
CREATE TABLE IF NOT EXISTS "Role" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(100) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
display_name varchar(255),
description text,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Permissions
CREATE TABLE IF NOT EXISTS "Permission" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(150) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
display_name varchar(255),
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- RolePermission
CREATE TABLE IF NOT EXISTS "RolePermission" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
permission_id uuid NOT NULL REFERENCES "Permission"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id)
);
CREATE INDEX IF NOT EXISTS idx_rolepermission_role_id ON "RolePermission" (role_id);
CREATE INDEX IF NOT EXISTS idx_rolepermission_permission_id ON "RolePermission" (permission_id);
-- UserRole
CREATE TABLE IF NOT EXISTS "UserRole" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
source "UserRoleSource" NOT NULL DEFAULT 'AUTHENTIK',
synced_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_userrole_user_id ON "UserRole" (user_id);
CREATE INDEX IF NOT EXISTS idx_userrole_role_id ON "UserRole" (role_id);
-- AuthGroupRoleMapping
CREATE TABLE IF NOT EXISTS "AuthGroupRoleMapping" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
auth_group varchar(255) NOT NULL,
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_authgroup_name ON "AuthGroupRoleMapping" (auth_group);
-- AuditLog
CREATE TABLE IF NOT EXISTS "AuditLog" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
action varchar(200) NOT NULL,
resource varchar(200),
resource_id text,
details jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_auditlog_user_id ON "AuditLog" (user_id);
CREATE INDEX IF NOT EXISTS idx_auditlog_action ON "AuditLog" (action);
COMMIT;
@@ -0,0 +1,37 @@
-- Phase 4 migration: add ScheduledJob and MediaObject
BEGIN;
-- ScheduledJob
CREATE TABLE IF NOT EXISTS "ScheduledJob" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(255) NOT NULL,
payload jsonb,
cron varchar(255),
run_at timestamptz,
status varchar(50) NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
last_run_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_scheduledjob_name ON "ScheduledJob" (name);
CREATE INDEX IF NOT EXISTS idx_scheduledjob_status ON "ScheduledJob" (status);
-- MediaObject
CREATE TABLE IF NOT EXISTS "MediaObject" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
storage_key varchar(1024) NOT NULL,
filename varchar(1024),
mime_type varchar(255),
size bigint,
is_public boolean NOT NULL DEFAULT false,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_media_owner_user_id ON "MediaObject" (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_media_storage_key ON "MediaObject" (storage_key);
COMMIT;
@@ -0,0 +1,18 @@
-- Migration: add Application table
CREATE TABLE IF NOT EXISTS "Application" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"code" varchar(100) NOT NULL UNIQUE,
"name" varchar(255) NOT NULL,
"description" text,
"icon" text,
"url" text,
"applicationsClaim" varchar(255) NOT NULL UNIQUE,
"isActive" boolean NOT NULL DEFAULT true,
"displayOrder" integer NOT NULL DEFAULT 0,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "Application_code_idx" ON "Application" ("code");
CREATE INDEX IF NOT EXISTS "Application_applicationsClaim_idx" ON "Application" ("applicationsClaim");
+149 -78
View File
@@ -1,5 +1,5 @@
// Prisma schema
// Basic User model for RayLab Core
// Prisma schema for RayLab Core - Phase 1
// PostgreSQL datasource. DATABASE_URL must be set in environment.
generator client {
provider = "prisma-client-js"
@@ -11,99 +11,170 @@ datasource db {
}
model User {
id String @id @default(uuid())
authentikId String? @unique
id String @id @default(uuid()) @db.Uuid
authentikId String? @unique
authentikUserId String? @unique
authentikSubject String? @unique
username String? @db.VarChar(255) @unique
email String? @db.VarChar(320) @unique
name String? @db.VarChar(255)
picture String?
isActive Boolean @default(true)
deletedAt DateTime?
lastSeenAt DateTime?
lastSyncedAt DateTime?
syncStatus String?
storageQuota BigInt? @default(10737418240)
storageUsed BigInt? @default(0)
metadata Json?
userRoles UserRole[]
auditLogs AuditLog[]
lastGroupHash String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
username String @unique
email String @unique
isActive Boolean @default(true)
// storage (in bytes)
storageQuota BigInt @default(10737418240)
storageUsed BigInt @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
lastSeenAt DateTime?
lastSyncedAt DateTime?
syncStatus String?
roles UserRole[]
permissions UserPermission[]
@@index([authentikId])
@@index([username])
@@index([email])
}
model Role {
id String @id @default(uuid())
code String @unique
name String
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(100)
name String @db.VarChar(255)
displayName String? @db.VarChar(255)
description String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users UserRole[]
permissions RolePermission[]
isDefault Boolean @default(false)
rolePermissions RolePermission[]
userRoles UserRole[]
mappings AuthGroupRoleMapping[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Permission {
id String @id @default(uuid())
code String @unique
name String
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(150)
name String @db.VarChar(255)
displayName String? @db.VarChar(255)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles RolePermission[]
users UserPermission[]
}
model UserRole {
userId String
roleId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, roleId])
rolePermissions RolePermission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RolePermission {
roleId String
permissionId String
id String @id @default(uuid()) @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
permissionId String @db.Uuid
createdAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([roleId, permissionId])
@@unique([roleId, permissionId])
@@index([roleId])
@@index([permissionId])
}
model UserPermission {
userId String
permissionId String
enum UserRoleSource {
AUTHENTIK
SYSTEM
}
model UserRole {
id String @id @default(uuid()) @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
source UserRoleSource @default(AUTHENTIK)
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
model AuthGroupRoleMapping {
id String @id @default(uuid()) @db.Uuid
authGroup String @db.VarChar(255)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authGroup])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId String? @db.Uuid
action String @db.VarChar(200)
resource String? @db.VarChar(200)
resourceId String?
details Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([action])
}
model ScheduledJob {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(255)
payload Json?
cron String?
runAt DateTime?
status String @db.VarChar(50) @default("pending")
attempts Int @default(0)
lastRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
@@index([status])
}
model MediaObject {
id String @id @default(uuid()) @db.Uuid
ownerUserId String? @db.Uuid
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
storageKey String @db.VarChar(1024)
filename String? @db.VarChar(1024)
mimeType String? @db.VarChar(255)
size BigInt?
isPublic Boolean @default(false)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ownerUserId])
@@index([storageKey])
}
model Application {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(100)
name String @db.VarChar(255)
description String?
icon String?
url String?
applicationsClaim String @unique @db.VarChar(255)
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([code])
@@index([applicationsClaim])
}
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now())
assignedBy String
@@id([userId, permissionId])
}
+171 -1
View File
@@ -1 +1,171 @@
// prisma seed placeholder
/**
* Prisma seed script for RayLab Core - Phase 1
* Run with: npx ts-node prisma/seed.ts
* Requires DATABASE_URL env var.
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding default roles, permissions, and mappings...');
// Default roles
const roles = [
{ name: 'Owner', displayName: 'Owner', description: 'Platform owner with full access' },
{ name: 'Employee', displayName: 'Employee', description: 'Employee role with internal permissions' },
{ name: 'Family', displayName: 'Family', description: 'Family role with limited access' },
];
for (const r of roles) {
const code = r.name.toLowerCase();
await prisma.role.upsert({
where: { code },
update: { name: r.name, displayName: r.displayName, description: r.description },
create: { code, name: r.name, displayName: r.displayName, description: r.description },
});
}
// Default permissions (resource.action lowercase as requested)
const permissions = [
'users.create',
'users.read',
'users.update',
'users.delete',
'roles.create',
'roles.read',
'roles.update',
'roles.delete',
'permissions.create',
'permissions.read',
'permissions.update',
'permissions.delete',
'audit.read',
'storage.read',
'storage.write',
'storage.delete',
'media.read',
'media.write',
'media.delete',
'scheduler.read',
'scheduler.manage',
];
for (const p of permissions) {
// Use code as canonical identifier (resource.action). Use name == code for now.
const code = p;
await prisma.permission.upsert({
where: { code },
update: { name: p, description: null },
create: { code, name: p, description: null },
});
}
// Assign permissions to roles (example mapping)
const owner = await prisma.role.findUnique({ where: { code: 'owner' } });
const employee = await prisma.role.findUnique({ where: { code: 'employee' } });
const family = await prisma.role.findUnique({ where: { code: 'family' } });
if (!owner || !employee || !family) {
throw new Error('Default roles missing after upsert');
}
// Owner -> all permissions
const allPerms = await prisma.permission.findMany();
for (const perm of allPerms) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: owner.id, permissionId: perm.id } },
update: {},
create: { roleId: owner.id, permissionId: perm.id },
});
}
// Employee -> a subset
const employeePerms = [
'users.read',
'permissions.read',
'scheduler.read',
'scheduler.manage',
'media.read',
'media.write',
];
for (const p of employeePerms) {
const perm = await prisma.permission.findUnique({ where: { code: p } });
if (perm) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: employee.id, permissionId: perm.id } },
update: {},
create: { roleId: employee.id, permissionId: perm.id },
});
}
}
// Family -> limited
const familyPerms = [
'media.read',
'media.write',
'media.delete',
];
for (const p of familyPerms) {
const perm = await prisma.permission.findUnique({ where: { code: p } });
if (perm) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: family.id, permissionId: perm.id } },
update: {},
create: { roleId: family.id, permissionId: perm.id },
});
}
}
// Authentik group mappings
const mappings = [
{ authGroup: 'RL-Owner', roleName: 'Owner' },
{ authGroup: 'RL-Employee', roleName: 'Employee' },
{ authGroup: 'RL-Family', roleName: 'Family' },
];
for (const m of mappings) {
const role = await prisma.role.findUnique({ where: { name: m.roleName } });
if (!role) continue;
// Ensure mapping exists (authGroup + roleId). AuthGroup can map to multiple roles.
const existing = await prisma.authGroupRoleMapping.findFirst({ where: { authGroup: m.authGroup, roleId: role.id } });
if (!existing) {
await prisma.authGroupRoleMapping.create({ data: { authGroup: m.authGroup, roleId: role.id } });
}
}
// Seed application catalog
const apps = [
{ code: 'core', name: 'Core', description: 'RayLab Core Dashboard', icon: 'mdi-apps', url: '/', applicationsClaim: 'core', displayOrder: 0 },
{ code: 'warung', name: 'Warung', description: 'Warung app', icon: 'mdi-store', url: '/warung', applicationsClaim: 'warung', displayOrder: 10 },
{ code: 'nextcloud', name: 'Nextcloud', description: 'Nextcloud', icon: 'mdi-cloud', url: 'https://nextcloud.example', applicationsClaim: 'nextcloud', displayOrder: 20 },
{ code: 'jellyfin', name: 'Jellyfin', description: 'Jellyfin media server', icon: 'mdi-movie', url: 'https://jellyfin.example', applicationsClaim: 'jellyfin', displayOrder: 30 },
{ code: 'forgejo', name: 'Forgejo', description: 'Forgejo git server', icon: 'mdi-source-repository', url: 'https://forgejo.example', applicationsClaim: 'forgejo', displayOrder: 40 },
{ code: 'immich', name: 'Immich', description: 'Immich photo server', icon: 'mdi-image', url: 'https://immich.example', applicationsClaim: 'immich', displayOrder: 50 },
{ code: 'grafana', name: 'Grafana', description: 'Grafana monitoring', icon: 'mdi-chart-line', url: 'https://grafana.example', applicationsClaim: 'grafana', displayOrder: 60 },
{ code: 'vaultwarden', name: 'Vaultwarden', description: 'Vaultwarden password manager', icon: 'mdi-lock', url: 'https://vaultwarden.example', applicationsClaim: 'vaultwarden', displayOrder: 70 },
{ code: 'homepage', name: 'Homepage', description: 'Homepage', icon: 'mdi-home', url: 'https://home.example', applicationsClaim: 'homepage', displayOrder: 80 },
];
for (const a of apps) {
await prisma.application.upsert({
where: { code: a.code },
update: { name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
create: { code: a.code, name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
});
}
console.log('Seeding completed.');
}
main()
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});