diff --git a/applyMigration.ts b/applyMigration.ts new file mode 100644 index 0000000..2e6bd5a --- /dev/null +++ b/applyMigration.ts @@ -0,0 +1,98 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import pg from 'pg'; +import * as schema from './shared/schema'; + +const { Pool } = pg; +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +const db = drizzle(pool, { schema }); + +async function main() { + console.log('Applying migrations...'); + + try { + // Apply the migrations + await migrate(db, { migrationsFolder: './migrations' }); + console.log('Migrations applied successfully'); + + // Insert default roles if they don't exist + await db.transaction(async (tx) => { + const adminRoleExists = await tx.query.roles.findFirst({ + where: (roles, { eq }) => eq(roles.name, 'admin') + }); + + if (!adminRoleExists) { + console.log('Creating admin role...'); + const [adminRole] = await tx.insert(schema.roles).values({ + name: 'admin', + description: 'Administrator with full system access', + isDefault: false + }).returning(); + + // Add all permissions to admin role + const permissions = Object.values(schema.PERMISSIONS); + await Promise.all(permissions.map(permission => + tx.insert(schema.rolePermissions).values({ + roleId: adminRole.id, + permission + }) + )); + } + + const userRoleExists = await tx.query.roles.findFirst({ + where: (roles, { eq }) => eq(roles.name, 'user') + }); + + if (!userRoleExists) { + console.log('Creating user role...'); + const [userRole] = await tx.insert(schema.roles).values({ + name: 'user', + description: 'Regular user with limited access', + isDefault: true + }).returning(); + + // Add basic permissions to user role + const basicPermissions = [ + schema.PERMISSIONS.VIEW_USERS, + schema.PERMISSIONS.VIEW_LDAP_CONNECTIONS, + schema.PERMISSIONS.VIEW_AD_USERS, + schema.PERMISSIONS.VIEW_AD_GROUPS, + schema.PERMISSIONS.VIEW_AD_OUS, + schema.PERMISSIONS.VIEW_AD_COMPUTERS, + schema.PERMISSIONS.VIEW_AD_DOMAINS + ]; + + await Promise.all(basicPermissions.map(permission => + tx.insert(schema.rolePermissions).values({ + roleId: userRole.id, + permission + }) + )); + } + + const apiRoleExists = await tx.query.roles.findFirst({ + where: (roles, { eq }) => eq(roles.name, 'api') + }); + + if (!apiRoleExists) { + console.log('Creating api role...'); + await tx.insert(schema.roles).values({ + name: 'api', + description: 'API access with customizable permissions', + isDefault: false + }); + } + }); + + console.log('Setup completed successfully'); + } catch (error) { + console.error('Migration failed:', error); + } finally { + await pool.end(); + } +} + +main(); diff --git a/client/src/components/dashboard/api-documentation-card.tsx b/client/src/components/dashboard/api-documentation-card.tsx index f8d2075..d7b72d5 100644 --- a/client/src/components/dashboard/api-documentation-card.tsx +++ b/client/src/components/dashboard/api-documentation-card.tsx @@ -15,50 +15,50 @@ export default function ApiDocumentationCard() {
-
-# Active Directory Management API
+
+

# Active Directory Management API

-## User Endpoints +

## User Endpoints

-GET /api/users -- Query Parameters: - - filter: Filter users by property values (e.g. ?filter=name eq 'John') - - select: Select specific properties to return (e.g. ?select=id,name,email) - - expand: Include related entities (e.g. ?expand=groups) - - orderBy: Order results (e.g. ?orderBy=name asc) - - top: Limit number of results (e.g. ?top=10) - - skip: Skip number of results (e.g. ?skip=10) +

GET /api/users

+

- Query Parameters:

+

- filter: Filter users by property values (e.g. ?filter=name eq 'John')

+

- select: Select specific properties to return (e.g. ?select=id,name,email)

+

- expand: Include related entities (e.g. ?expand=groups)

+

- orderBy: Order results (e.g. ?orderBy=name asc)

+

- top: Limit number of results (e.g. ?top=10)

+

- skip: Skip number of results (e.g. ?skip=10)

-POST /api/users -- Create a new user in Active Directory -- Request body: User object +

POST /api/users

+

- Create a new user in Active Directory

+

- Request body: User object

-GET /api/users/{id} -- Get a specific user by ID -- Query Parameters: - - select: Select specific properties to return +

GET /api/users/{"{userId}"}

+

- Get a specific user by ID

+

- Query Parameters:

+

- select: Select specific properties to return

-PUT /api/users/{id} -- Update a specific user -- Request body: User object with updated properties +

PUT /api/users/{"{userId}"}

+

- Update a specific user

+

- Request body: User object with updated properties

-DELETE /api/users/{id} -- Delete a specific user -
+

DELETE /api/users/{"{userId}"}

+

- Delete a specific user

+
diff --git a/client/src/index.css b/client/src/index.css index 0cf721b..2439cdb 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -2,6 +2,16 @@ @tailwind components; @tailwind utilities; +@layer components { + .drawer-item { + @apply text-gray-700 hover:bg-gray-100 hover:text-primary focus:outline-none transition-colors duration-200; + } + + .drawer-item.active { + @apply bg-primary/10 text-primary font-medium; + } +} + @layer base { :root { --background: 0 0% 96%; diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx index 53c9e95..bd72bad 100644 --- a/client/src/layouts/dashboard-layout.tsx +++ b/client/src/layouts/dashboard-layout.tsx @@ -160,22 +160,21 @@ export function DashboardLayout({ children, title, description }: DashboardLayou } ${isMobile ? "absolute" : "relative"}`} >
- - AD Management API + + AD Management API
- - - - Dashboard - + + + Dashboard {menuSections.map((section, idx) => ( @@ -185,15 +184,15 @@ export function DashboardLayout({ children, title, description }: DashboardLayou
{section.items.map((item, itemIdx) => ( - - - {item.icon} - {item.title} - + + {item.icon} + {item.title} ))}
diff --git a/client/src/pages/auth-page.tsx b/client/src/pages/auth-page.tsx index 38daeba..1b8eb6b 100644 --- a/client/src/pages/auth-page.tsx +++ b/client/src/pages/auth-page.tsx @@ -42,6 +42,8 @@ const registerSchema = insertUserSchema.extend({ acceptTerms: z.boolean().refine(val => val === true, { message: "You must accept the terms and conditions", }), + // Define roleId explicitly to match form values + roleId: z.number().optional().nullable(), }).refine(data => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], @@ -140,7 +142,7 @@ export default function AuthPage() { email: "", fullName: "", acceptTerms: false, - role: "user", + roleId: 2, // Assign the default "user" role ID }, }); diff --git a/migrations/0000_fancy_blackheart.sql b/migrations/0000_fancy_blackheart.sql new file mode 100644 index 0000000..bfa2e31 --- /dev/null +++ b/migrations/0000_fancy_blackheart.sql @@ -0,0 +1,116 @@ +CREATE TABLE "ad_computers" ( + "id" serial PRIMARY KEY NOT NULL, + "connection_id" integer NOT NULL, + "distinguished_name" text NOT NULL, + "name" text NOT NULL, + "dns_host_name" text, + "operating_system" text, + "operating_system_version" text, + "last_logon" timestamp, + "enabled" boolean DEFAULT true, + "ad_properties" jsonb +); +--> statement-breakpoint +CREATE TABLE "ad_domains" ( + "id" serial PRIMARY KEY NOT NULL, + "connection_id" integer NOT NULL, + "distinguished_name" text NOT NULL, + "name" text NOT NULL, + "net_bios_name" text, + "forest_name" text, + "domain_functionality" text, + "ad_properties" jsonb +); +--> statement-breakpoint +CREATE TABLE "ad_groups" ( + "id" serial PRIMARY KEY NOT NULL, + "connection_id" integer NOT NULL, + "distinguished_name" text NOT NULL, + "sam_account_name" text NOT NULL, + "group_type" text, + "description" text, + "members" jsonb, + "ad_properties" jsonb +); +--> statement-breakpoint +CREATE TABLE "ad_org_units" ( + "id" serial PRIMARY KEY NOT NULL, + "connection_id" integer NOT NULL, + "distinguished_name" text NOT NULL, + "name" text NOT NULL, + "description" text, + "ad_properties" jsonb +); +--> statement-breakpoint +CREATE TABLE "ad_users" ( + "id" serial PRIMARY KEY NOT NULL, + "connection_id" integer NOT NULL, + "distinguished_name" text NOT NULL, + "sam_account_name" text NOT NULL, + "user_principal_name" text, + "given_name" text, + "surname" text, + "display_name" text, + "email" text, + "enabled" boolean DEFAULT true, + "last_logon" timestamp, + "member_of" jsonb, + "ad_properties" jsonb +); +--> statement-breakpoint +CREATE TABLE "api_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "token" text NOT NULL, + "user_id" integer NOT NULL, + "role_id" integer, + "custom_permissions" jsonb, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "api_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "ldap_connections" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "server" text NOT NULL, + "domain" text NOT NULL, + "port" integer DEFAULT 389, + "use_ssl" boolean DEFAULT true, + "username" text NOT NULL, + "password" text NOT NULL, + "status" text DEFAULT 'disconnected', + "last_connected" timestamp, + "created_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "role_permissions" ( + "role_id" integer NOT NULL, + "permission" text NOT NULL, + CONSTRAINT "role_permissions_role_id_permission_pk" PRIMARY KEY("role_id","permission") +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "is_default" boolean DEFAULT false, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "password" text NOT NULL, + "email" text, + "full_name" text, + "role_id" integer, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..6938d9d --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,714 @@ +{ + "id": "fbd1e34a-7d4a-41c2-9c99-cfda190d6502", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ad_computers": { + "name": "ad_computers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distinguished_name": { + "name": "distinguished_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dns_host_name": { + "name": "dns_host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operating_system": { + "name": "operating_system", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operating_system_version": { + "name": "operating_system_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_logon": { + "name": "last_logon", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "ad_properties": { + "name": "ad_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_domains": { + "name": "ad_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distinguished_name": { + "name": "distinguished_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "net_bios_name": { + "name": "net_bios_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forest_name": { + "name": "forest_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_functionality": { + "name": "domain_functionality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_properties": { + "name": "ad_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_groups": { + "name": "ad_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distinguished_name": { + "name": "distinguished_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sam_account_name": { + "name": "sam_account_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_type": { + "name": "group_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "members": { + "name": "members", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ad_properties": { + "name": "ad_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_org_units": { + "name": "ad_org_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distinguished_name": { + "name": "distinguished_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_properties": { + "name": "ad_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_users": { + "name": "ad_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distinguished_name": { + "name": "distinguished_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sam_account_name": { + "name": "sam_account_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_principal_name": { + "name": "user_principal_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surname": { + "name": "surname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_logon": { + "name": "last_logon", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "member_of": { + "name": "member_of", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ad_properties": { + "name": "ad_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "custom_permissions": { + "name": "custom_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_tokens_role_id_roles_id_fk": { + "name": "api_tokens_role_id_roles_id_fk", + "tableFrom": "api_tokens", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ldap_connections": { + "name": "ldap_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server": { + "name": "server", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 389 + }, + "use_ssl": { + "name": "use_ssl", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_pk": { + "name": "role_permissions_role_id_permission_pk", + "columns": [ + "role_id", + "permission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_role_id_roles_id_fk": { + "name": "users_role_id_roles_id_fk", + "tableFrom": "users", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 0000000..ce595e1 --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1744078354259, + "tag": "0000_fancy_blackheart", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index adb3b9c..e080ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,10 @@ "@radix-ui/react-tooltip": "^1.1.3", "@replit/vite-plugin-shadcn-theme-json": "^0.0.4", "@tanstack/react-query": "^5.60.5", + "@types/jsonwebtoken": "^9.0.9", + "@types/passport-jwt": "^4.0.1", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -59,6 +63,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pg": "^8.14.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -3285,7 +3290,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -3296,7 +3300,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3388,7 +3391,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -3401,7 +3403,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3424,7 +3425,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3433,11 +3433,26 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/node": { @@ -3453,12 +3468,21 @@ "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" } }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, "node_modules/@types/passport-local": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", @@ -3475,7 +3499,6 @@ "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", - "dev": true, "license": "MIT", "dependencies": { "@types/express": "*", @@ -3504,14 +3527,12 @@ "version": "6.9.16", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -3539,7 +3560,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -3550,7 +3570,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -3558,6 +3577,22 @@ "@types/send": "*" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -6436,14 +6471,14 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pg": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", - "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", + "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.7.0", - "pg-pool": "^3.7.0", - "pg-protocol": "^1.7.0", + "pg-pool": "^3.8.0", + "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -6494,18 +6529,18 @@ } }, "node_modules/pg-pool": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", - "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", - "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", "license": "MIT" }, "node_modules/pg-types": { diff --git a/package.json b/package.json index bcc5ad2..94c0fae 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "@radix-ui/react-tooltip": "^1.1.3", "@replit/vite-plugin-shadcn-theme-json": "^0.0.4", "@tanstack/react-query": "^5.60.5", + "@types/jsonwebtoken": "^9.0.9", + "@types/passport-jwt": "^4.0.1", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -61,6 +65,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pg": "^8.14.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/server/auth.ts b/server/auth.ts index 5e93955..b405616 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -110,12 +110,19 @@ export function setupAuth(app: Express) { } const hashedPassword = await hashPassword(password); + // Get the default role if role ID isn't specified + let roleId = req.body.roleId; + if (!roleId) { + const defaultRole = await storage.getDefaultRole(); + roleId = defaultRole?.id; + } + const user = await storage.createUser({ username, password: hashedPassword, email: req.body.email, fullName: req.body.fullName, - role: req.body.role || "user", + roleId: roleId, }); // Remove password from response @@ -132,7 +139,7 @@ export function setupAuth(app: Express) { // Login endpoint app.post("/api/login", (req, res, next) => { - passport.authenticate("local", (err, user, info) => { + passport.authenticate("local", (err: any, user: any, info: any) => { if (err) return next(err); if (!user) { return res.status(401).json({ message: info?.message || "Authentication failed" }); @@ -175,25 +182,30 @@ export function setupAuth(app: Express) { } try { - const { name, expiresAt, permissions } = req.body; + const { name, expiresAt, roleId, customPermissions } = req.body; if (!name) { return res.status(400).json({ message: "Token name is required" }); } + // Create JWT token with user ID and optional permissions const token = jwt.sign( { sub: req.user.id, - permissions + customPermissions }, JWT_SECRET, - { expiresAt: expiresAt ? new Date(expiresAt) : undefined } + { + expiresIn: expiresAt ? Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000) : '365d' + } ); + // Store the token in the database const apiToken = storage.createApiToken({ name, token, userId: req.user.id, - permissions: permissions || {}, + roleId: roleId || null, + customPermissions: customPermissions || null, expiresAt: expiresAt ? new Date(expiresAt) : null, }); diff --git a/server/authorization.ts b/server/authorization.ts new file mode 100644 index 0000000..07eb575 --- /dev/null +++ b/server/authorization.ts @@ -0,0 +1,194 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "./db"; +import { roles, rolePermissions, users, apiTokens } from "@shared/schema"; +import { eq, or, and, inArray } from "drizzle-orm"; + +// Types for role-based access control +export type RequireAuthOptions = { + allowApiToken?: boolean; +}; + +export type RequirePermissionOptions = RequireAuthOptions & { + anyOf?: string[]; + allOf?: string[]; +}; + +// Default middleware that verifies a user is authenticated +export function requireAuth(options: RequireAuthOptions = {}) { + return async (req: Request, res: Response, next: NextFunction) => { + // Check for session authentication + if (req.isAuthenticated()) { + return next(); + } + + // Check for API token authentication if allowed + if (options.allowApiToken) { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.substring(7); + + try { + // Lookup the token + const [apiToken] = await db.select() + .from(apiTokens) + .where(eq(apiTokens.token, token)) + .limit(1); + + if (!apiToken) { + return res.status(401).json({ message: "Invalid API token" }); + } + + // Check if token has expired + if (apiToken.expiresAt && new Date(apiToken.expiresAt) < new Date()) { + return res.status(401).json({ message: "API token has expired" }); + } + + // Set token info on the request + req.user = { + id: apiToken.userId, + username: '', + password: '', + fullName: null, + email: null, + createdAt: null, + // Add tokenId for identifying which token was used + tokenId: apiToken.id, + // Add roleId for permission checking + roleId: apiToken.roleId, + // Store custom permissions if available + customPermissions: (Array.isArray(apiToken.customPermissions) ? + apiToken.customPermissions : + (apiToken.customPermissions ? JSON.parse(String(apiToken.customPermissions)) : [])) as string[] + }; + + return next(); + } catch (error) { + console.error("API token authentication error:", error); + return res.status(500).json({ message: "Internal server error" }); + } + } + } + + // Not authenticated through any method + return res.status(401).json({ message: "Not authenticated" }); + }; +} + +// Middleware for checking specific permissions +export function requirePermission(permission: string, options: RequirePermissionOptions = {}) { + return async (req: Request, res: Response, next: NextFunction) => { + // First ensure the user is authenticated + const authMiddleware = requireAuth(options); + + authMiddleware(req, res, async () => { + try { + if (!req.user) { + return res.status(401).json({ message: "Not authenticated" }); + } + + // Get the permissions for the user's role + let hasPermission = false; + + // Check for API token custom permissions first if present + if (req.user?.tokenId && Array.isArray(req.user?.customPermissions)) { + // For API tokens with custom permissions + const customPermissions = req.user.customPermissions; + if (customPermissions.includes(permission) || + options.anyOf?.some(p => customPermissions.includes(p))) { + hasPermission = true; + } + } + + // If no permission yet, check role-based permissions + if (!hasPermission && req.user.roleId) { + // Get permissions assigned to the role + const rolePerms = await db.select() + .from(rolePermissions) + .where(eq(rolePermissions.roleId, req.user.roleId)); + + const userPermissions = rolePerms.map(rp => rp.permission); + + // Check for the specific permission or any of the optional permissions + if (userPermissions.includes(permission) || + options.anyOf?.some(p => userPermissions.includes(p))) { + hasPermission = true; + } + + // If allOf is specified, ensure all required permissions are present + if (options.allOf && options.allOf.length > 0) { + hasPermission = options.allOf.every(p => userPermissions.includes(p)); + } + } + + // Check if user has the admin:system permission which grants all access + if (!hasPermission && req.user.roleId) { + const adminPerm = await db.select() + .from(rolePermissions) + .where(and( + eq(rolePermissions.roleId, req.user.roleId), + eq(rolePermissions.permission, "admin:system") + )) + .limit(1); + + if (adminPerm.length > 0) { + hasPermission = true; + } + } + + if (hasPermission) { + return next(); + } + + return res.status(403).json({ message: "Insufficient permissions" }); + } catch (error) { + console.error("Permission check error:", error); + return res.status(500).json({ message: "Internal server error" }); + } + }); + }; +} + +// Convenience middleware for requiring administrative access +export function requireAdmin() { + return requirePermission("admin:system"); +} + +// Get permissions for a user +export async function getUserPermissions(userId: number): Promise { + const [user] = await db.select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user || !user.roleId) { + return []; + } + + const rolePerms = await db.select() + .from(rolePermissions) + .where(eq(rolePermissions.roleId, user.roleId)); + + return rolePerms.map(rp => rp.permission); +} + +// Initialize on server startup to ensure default roles +export async function initializeRBAC() { + try { + // Check for existing data before doing anything + const [adminRole] = await db.select() + .from(roles) + .where(eq(roles.name, "admin")) + .limit(1); + + if (adminRole) { + console.log("RBAC already initialized"); + return true; + } + + console.log("RBAC initialization skipped - should be handled by updateSchema.ts"); + return true; + } catch (error) { + console.error("Failed to initialize RBAC:", error); + return false; + } +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 4de0dcf..706d4e4 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -3,8 +3,14 @@ import { createServer, type Server } from "http"; import { setupAuth } from "./auth"; import { setupSwagger } from "./swagger"; import { storage } from "./storage"; -import { apiQuerySchema } from "@shared/schema"; +import { apiQuerySchema, PERMISSIONS } from "@shared/schema"; import { ZodError } from "zod"; +import { + requireAuth, + requirePermission, + requireAdmin, + initializeRBAC +} from "./authorization"; export async function registerRoutes(app: Express): Promise { // Setup authentication @@ -13,6 +19,9 @@ export async function registerRoutes(app: Express): Promise { // Setup Swagger documentation setupSwagger(app); + // Initialize Role Based Access Control system + await initializeRBAC(); + // Error handler for Zod validation errors const handleZodError = (err: ZodError, res: Response) => { return res.status(400).json({ @@ -33,14 +42,6 @@ export async function registerRoutes(app: Express): Promise { } }; - // Middleware to check admin role - const requireAdmin = (req: Request, res: Response, next: NextFunction) => { - if (!req.isAuthenticated() || req.user.role !== "admin") { - return res.status(403).json({ message: "Access denied: Admin role required" }); - } - next(); - }; - /** * @swagger * /api/ldap-connections: @@ -49,6 +50,7 @@ export async function registerRoutes(app: Express): Promise { * tags: [LDAP Connections] * security: * - cookieAuth: [] + * - bearerAuth: [] * responses: * 200: * description: A list of LDAP connections @@ -60,13 +62,11 @@ export async function registerRoutes(app: Express): Promise { * $ref: '#/components/schemas/LdapConnection' * 401: * $ref: '#/components/responses/UnauthorizedError' + * 403: + * $ref: '#/components/responses/ForbiddenError' */ - app.get("/api/ldap-connections", async (req, res, next) => { + app.get("/api/ldap-connections", requirePermission(PERMISSIONS.VIEW_LDAP_CONNECTIONS, { allowApiToken: true }), async (req, res, next) => { try { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Not authenticated" }); - } - const connections = await storage.listLdapConnections(); // Hide sensitive fields like password @@ -360,8 +360,13 @@ export async function registerRoutes(app: Express): Promise { } // Only allow users to delete their own tokens unless they're admin - if (token.userId !== req.user.id && req.user.role !== "admin") { - return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" }); + if (token.userId !== req.user.id) { + // Get the user's role + const userRole = await storage.getRole(req.user.roleId!); + + if (userRole?.name !== "admin") { + return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" }); + } } const deleted = await storage.deleteApiToken(parseInt(req.params.id)); diff --git a/server/storage.ts b/server/storage.ts index a1cf450..5cc83ab 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -3,8 +3,9 @@ import { LdapConnection, InsertLdapConnection, AdUser, InsertAdUser, AdGroup, InsertAdGroup, AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer, - AdDomain, InsertAdDomain, - users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains + AdDomain, InsertAdDomain, Role, + users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains, + roles } from "@shared/schema"; import session from "express-session"; import createMemoryStore from "memorystore"; @@ -32,7 +33,11 @@ export interface IStorage { updateUser(id: number, user: Partial): Promise; deleteUser(id: number): Promise; listUsers(): Promise; - + + // Role management + getRole(id: number): Promise; + getDefaultRole(): Promise; + // API Token management getApiToken(id: number): Promise; getApiTokenByToken(token: string): Promise; @@ -126,6 +131,17 @@ export class DatabaseStorage implements IStorage { async listUsers(): Promise { return db.select().from(users); } + + // Role management + async getRole(id: number): Promise { + const result = await db.select().from(roles).where(eq(roles.id, id)); + return result.length > 0 ? result[0] : undefined; + } + + async getDefaultRole(): Promise { + const result = await db.select().from(roles).where(eq(roles.isDefault, true)); + return result.length > 0 ? result[0] : undefined; + } // API Token management async getApiToken(id: number): Promise { @@ -210,7 +226,7 @@ export class DatabaseStorage implements IStorage { return users.map(user => { const result: any = { id: user.id }; - properties.forEach(prop => { + properties.forEach((prop: string) => { if ((user as any)[prop] !== undefined) { result[prop] = (user as any)[prop]; } @@ -253,7 +269,7 @@ export class DatabaseStorage implements IStorage { return groups.map(group => { const result: any = { id: group.id }; - properties.forEach(prop => { + properties.forEach((prop: string) => { if ((group as any)[prop] !== undefined) { result[prop] = (group as any)[prop]; } @@ -296,7 +312,7 @@ export class DatabaseStorage implements IStorage { return orgUnits.map(ou => { const result: any = { id: ou.id }; - properties.forEach(prop => { + properties.forEach((prop: string) => { if ((ou as any)[prop] !== undefined) { result[prop] = (ou as any)[prop]; } @@ -339,7 +355,7 @@ export class DatabaseStorage implements IStorage { return computers.map(computer => { const result: any = { id: computer.id }; - properties.forEach(prop => { + properties.forEach((prop: string) => { if ((computer as any)[prop] !== undefined) { result[prop] = (computer as any)[prop]; } @@ -382,7 +398,7 @@ export class DatabaseStorage implements IStorage { return domains.map(domain => { const result: any = { id: domain.id }; - properties.forEach(prop => { + properties.forEach((prop: string) => { if ((domain as any)[prop] !== undefined) { result[prop] = (domain as any)[prop]; } diff --git a/server/swagger.ts b/server/swagger.ts index 16d13c8..1771889 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -53,7 +53,7 @@ const swaggerOptions = { name: { type: "string" }, token: { type: "string" }, userId: { type: "integer" }, - permissions: { type: "object" }, + customPermissions: { type: "array", items: { type: "string" } }, expiresAt: { type: "string", format: "date-time" }, createdAt: { type: "string", format: "date-time" }, }, @@ -240,9 +240,18 @@ const swaggerOptions = { const swaggerSpec = swaggerJsdoc(swaggerOptions); export function setupSwagger(app: Express) { + // Mount at both paths for backward compatibility app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); + + // Provide the JSON spec at multiple paths app.get("/api/swagger.json", (req, res) => { res.setHeader("Content-Type", "application/json"); res.send(swaggerSpec); }); + + app.get("/api-docs/swagger.json", (req, res) => { + res.setHeader("Content-Type", "application/json"); + res.send(swaggerSpec); + }); } diff --git a/shared/schema.ts b/shared/schema.ts index ca48c23..9186d0b 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1,6 +1,104 @@ -import { pgTable, text, serial, integer, boolean, timestamp, jsonb } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, integer, boolean, timestamp, jsonb, primaryKey } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { relations } from "drizzle-orm"; + +// Role-based access control tables +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + name: text("name").notNull().unique(), + description: text("description"), + isDefault: boolean("is_default").default(false), + createdAt: timestamp("created_at").defaultNow(), +}); + +// Define available permissions +export const PERMISSIONS = { + // User management + VIEW_USERS: "view:users", + CREATE_USERS: "create:users", + UPDATE_USERS: "update:users", + DELETE_USERS: "delete:users", + + // LDAP connections + VIEW_LDAP_CONNECTIONS: "view:ldap_connections", + CREATE_LDAP_CONNECTIONS: "create:ldap_connections", + UPDATE_LDAP_CONNECTIONS: "update:ldap_connections", + DELETE_LDAP_CONNECTIONS: "delete:ldap_connections", + + // Active Directory management + VIEW_AD_USERS: "view:ad_users", + CREATE_AD_USERS: "create:ad_users", + UPDATE_AD_USERS: "update:ad_users", + DELETE_AD_USERS: "delete:ad_users", + + VIEW_AD_GROUPS: "view:ad_groups", + CREATE_AD_GROUPS: "create:ad_groups", + UPDATE_AD_GROUPS: "update:ad_groups", + DELETE_AD_GROUPS: "delete:ad_groups", + + VIEW_AD_OUS: "view:ad_ous", + CREATE_AD_OUS: "create:ad_ous", + UPDATE_AD_OUS: "update:ad_ous", + DELETE_AD_OUS: "delete:ad_ous", + + VIEW_AD_COMPUTERS: "view:ad_computers", + CREATE_AD_COMPUTERS: "create:ad_computers", + UPDATE_AD_COMPUTERS: "update:ad_computers", + DELETE_AD_COMPUTERS: "delete:ad_computers", + + VIEW_AD_DOMAINS: "view:ad_domains", + + // API Token management + MANAGE_API_TOKENS: "manage:api_tokens", + + // Administrative functions + MANAGE_ROLES: "manage:roles", + SYSTEM_ADMIN: "admin:system", +} as const; + +// Create a Zod schema for permissions +export const permissionsSchema = z.enum([ + "view:users", + "create:users", + "update:users", + "delete:users", + "view:ldap_connections", + "create:ldap_connections", + "update:ldap_connections", + "delete:ldap_connections", + "view:ad_users", + "create:ad_users", + "update:ad_users", + "delete:ad_users", + "view:ad_groups", + "create:ad_groups", + "update:ad_groups", + "delete:ad_groups", + "view:ad_ous", + "create:ad_ous", + "update:ad_ous", + "delete:ad_ous", + "view:ad_computers", + "create:ad_computers", + "update:ad_computers", + "delete:ad_computers", + "view:ad_domains", + "manage:api_tokens", + "manage:roles", + "admin:system" +]); + +export type Permission = z.infer; + +export const rolePermissions = pgTable("role_permissions", { + roleId: integer("role_id").notNull().references(() => roles.id, { onDelete: "cascade" }), + permission: text("permission").notNull(), +}, table => { + return { + pk: primaryKey({ columns: [table.roleId, table.permission] }), + }; +}); // User schema for local authentication export const users = pgTable("users", { @@ -9,7 +107,7 @@ export const users = pgTable("users", { password: text("password").notNull(), email: text("email"), fullName: text("full_name"), - role: text("role").default("user"), + roleId: integer("role_id").references(() => roles.id), createdAt: timestamp("created_at").defaultNow(), }); @@ -18,8 +116,9 @@ export const apiTokens = pgTable("api_tokens", { id: serial("id").primaryKey(), name: text("name").notNull(), token: text("token").notNull().unique(), - userId: integer("user_id").notNull(), - permissions: jsonb("permissions").notNull(), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id").references(() => roles.id), + customPermissions: jsonb("custom_permissions"), expiresAt: timestamp("expires_at"), createdAt: timestamp("created_at").defaultNow(), }); @@ -100,7 +199,42 @@ export const adDomains = pgTable("ad_domains", { adProperties: jsonb("ad_properties"), }); +// Define relations between tables +export const rolesRelations = relations(roles, ({ many }) => ({ + permissions: many(rolePermissions), + users: many(users), + apiTokens: many(apiTokens), +})); + +export const rolePermissionsRelations = relations(rolePermissions, ({ one }) => ({ + role: one(roles, { + fields: [rolePermissions.roleId], + references: [roles.id], + }), +})); + +export const usersRelations = relations(users, ({ one, many }) => ({ + role: one(roles, { + fields: [users.roleId], + references: [roles.id], + }), + apiTokens: many(apiTokens), +})); + +export const apiTokensRelations = relations(apiTokens, ({ one }) => ({ + user: one(users, { + fields: [apiTokens.userId], + references: [users.id], + }), + role: one(roles, { + fields: [apiTokens.roleId], + references: [roles.id], + }), +})); + // Generate insertion schemas +export const insertRoleSchema = createInsertSchema(roles).omit({ id: true, createdAt: true }); +export const insertRolePermissionSchema = createInsertSchema(rolePermissions); export const insertUserSchema = createInsertSchema(users).omit({ id: true, createdAt: true }); export const insertApiTokenSchema = createInsertSchema(apiTokens).omit({ id: true, createdAt: true }); export const insertLdapConnectionSchema = createInsertSchema(ldapConnections).omit({ id: true, createdAt: true, lastConnected: true }); @@ -127,7 +261,16 @@ export const apiQuerySchema = z.object({ }); // Export types -export type User = typeof users.$inferSelect; +export type Role = typeof roles.$inferSelect; +export type InsertRole = z.infer; +export type RolePermission = typeof rolePermissions.$inferSelect; +export type InsertRolePermission = z.infer; +export type User = typeof users.$inferSelect & { + // For API token authentication + tokenId?: number; + customPermissions?: string[]; + role?: string; +}; export type InsertUser = z.infer; export type ApiToken = typeof apiTokens.$inferSelect; export type InsertApiToken = z.infer; diff --git a/updateSchema.ts b/updateSchema.ts new file mode 100644 index 0000000..0307099 --- /dev/null +++ b/updateSchema.ts @@ -0,0 +1,189 @@ +import pg from 'pg'; +import * as schema from './shared/schema'; + +const { Pool } = pg; +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function main() { + console.log('Updating database schema...'); + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Create roles table if it doesn't exist + await client.query(` + CREATE TABLE IF NOT EXISTS "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "description" text, + "is_default" boolean DEFAULT false, + "created_at" timestamp DEFAULT now(), + CONSTRAINT "roles_name_unique" UNIQUE("name") + ); + `); + + // Create role_permissions table if it doesn't exist + await client.query(` + CREATE TABLE IF NOT EXISTS "role_permissions" ( + "role_id" integer NOT NULL, + "permission" text NOT NULL, + CONSTRAINT "role_permissions_role_id_permission_pk" PRIMARY KEY("role_id","permission") + ); + `); + + // Add foreign key constraint to role_permissions table + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'role_permissions_role_id_roles_id_fk' + ) THEN + ALTER TABLE "role_permissions" + ADD CONSTRAINT "role_permissions_role_id_roles_id_fk" + FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE cascade; + END IF; + END $$; + `); + + // Update users table to add role_id column if it doesn't exist + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'role_id' + ) THEN + ALTER TABLE "users" ADD COLUMN "role_id" integer; + + ALTER TABLE "users" + ADD CONSTRAINT "users_role_id_roles_id_fk" + FOREIGN KEY ("role_id") REFERENCES "roles"("id"); + END IF; + END $$; + `); + + // Update api_tokens table + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'api_tokens' AND column_name = 'role_id' + ) THEN + ALTER TABLE "api_tokens" ADD COLUMN "role_id" integer; + + ALTER TABLE "api_tokens" + ADD CONSTRAINT "api_tokens_role_id_roles_id_fk" + FOREIGN KEY ("role_id") REFERENCES "roles"("id"); + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'api_tokens' AND column_name = 'permissions' + ) THEN + ALTER TABLE "api_tokens" RENAME COLUMN "permissions" TO "custom_permissions"; + END IF; + END $$; + `); + + await client.query('COMMIT'); + console.log('Schema update completed'); + + // Insert default roles if they don't exist + await client.query('BEGIN'); + + const { rows: adminExists } = await client.query(` + SELECT id FROM roles WHERE name = 'admin' LIMIT 1 + `); + + if (adminExists.length === 0) { + console.log('Creating admin role...'); + const { rows: adminRole } = await client.query(` + INSERT INTO roles (name, description, is_default) + VALUES ('admin', 'Administrator with full system access', false) + RETURNING id + `); + + // Add all permissions to admin role + for (const permission of Object.values(schema.PERMISSIONS)) { + await client.query(` + INSERT INTO role_permissions (role_id, permission) + VALUES ($1, $2) + `, [adminRole[0].id, permission]); + } + } + + const { rows: userExists } = await client.query(` + SELECT id FROM roles WHERE name = 'user' LIMIT 1 + `); + + if (userExists.length === 0) { + console.log('Creating user role...'); + const { rows: userRole } = await client.query(` + INSERT INTO roles (name, description, is_default) + VALUES ('user', 'Regular user with limited access', true) + RETURNING id + `); + + // Add basic permissions to user role + const basicPermissions = [ + schema.PERMISSIONS.VIEW_USERS, + schema.PERMISSIONS.VIEW_LDAP_CONNECTIONS, + schema.PERMISSIONS.VIEW_AD_USERS, + schema.PERMISSIONS.VIEW_AD_GROUPS, + schema.PERMISSIONS.VIEW_AD_OUS, + schema.PERMISSIONS.VIEW_AD_COMPUTERS, + schema.PERMISSIONS.VIEW_AD_DOMAINS + ]; + + for (const permission of basicPermissions) { + await client.query(` + INSERT INTO role_permissions (role_id, permission) + VALUES ($1, $2) + `, [userRole[0].id, permission]); + } + } + + const { rows: apiExists } = await client.query(` + SELECT id FROM roles WHERE name = 'api' LIMIT 1 + `); + + if (apiExists.length === 0) { + console.log('Creating api role...'); + await client.query(` + INSERT INTO roles (name, description, is_default) + VALUES ('api', 'API access with customizable permissions', false) + `); + } + + // Update existing users to have the admin role if they have no role assigned + await client.query(` + DO $$ + DECLARE + admin_role_id integer; + BEGIN + SELECT id INTO admin_role_id FROM roles WHERE name = 'admin' LIMIT 1; + + IF admin_role_id IS NOT NULL THEN + UPDATE users SET role_id = admin_role_id WHERE role_id IS NULL; + END IF; + END $$; + `); + + await client.query('COMMIT'); + console.log('Data migration completed successfully'); + + } catch (error) { + await client.query('ROLLBACK'); + console.error('Migration failed:', error); + } finally { + client.release(); + await pool.end(); + } +} + +main();