Migrating user service to database, sunsetting admin user

This commit is contained in:
Wayne
2025-08-06 00:01:15 +03:00
parent 3201fbfe0b
commit 842f8092d6
27 changed files with 3174 additions and 91 deletions

View File

@@ -0,0 +1,111 @@
# IAM Policies Guide
This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions.
## 1. Policy Structure
A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`.
```json
{
"Effect": "Allow",
"Action": ["archive:read", "archive:search"],
"Resource": ["archive/all"]
}
```
- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`.
- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`.
- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used.
## 2. Actions and Resources by Service
The following sections define the available actions and resources, categorized by their respective services.
### Service: `archive`
The `archive` service pertains to all actions related to accessing and managing archived emails.
**Actions:**
| Action | Description |
| :--------------- | :--------------------------------------------------------------------- |
| `archive:read` | Grants permission to read the content and metadata of archived emails. |
| `archive:search` | Grants permission to perform search queries against the email archive. |
| `archive:export` | Grants permission to export search results or individual emails. |
**Resources:**
| Resource | Description |
| :------------------------------------ | :------------------------------------------------------------- |
| `archive/all` | Represents the entire email archive. |
| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. |
| `archive/email/{emailId}` | Scopes the action to a single, specific email. |
| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. |
---
### Service: `ingestion`
The `ingestion` service covers the management of email ingestion sources.
**Actions:**
| Action | Description |
| :----------------------- | :--------------------------------------------------------------------------- |
| `ingestion:createSource` | Grants permission to create a new ingestion source. |
| `ingestion:readSource` | Grants permission to view the details of ingestion sources. |
| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. |
| `ingestion:deleteSource` | Grants permission to delete an ingestion source. |
| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. |
**Resources:**
| Resource | Description |
| :---------------------------- | :-------------------------------------------------------- |
| `ingestion-source/*` | Represents all ingestion sources. |
| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. |
---
### Service: `system`
The `system` service is for managing system-level settings, users, and roles.
**Actions:**
| Action | Description |
| :---------------------- | :-------------------------------------------------- |
| `system:readSettings` | Grants permission to view system settings. |
| `system:updateSettings` | Grants permission to modify system settings. |
| `system:readUsers` | Grants permission to list and view user accounts. |
| `system:createUser` | Grants permission to create new user accounts. |
| `system:updateUser` | Grants permission to modify existing user accounts. |
| `system:deleteUser` | Grants permission to delete user accounts. |
| `system:assignRole` | Grants permission to assign roles to users. |
**Resources:**
| Resource | Description |
| :--------------------- | :---------------------------------------------------- |
| `system/settings` | Represents the system configuration. |
| `system/users` | Represents all user accounts within the system. |
| `system/user/{userId}` | Scopes the action to a single, specific user account. |
---
### Service: `dashboard`
The `dashboard` service relates to viewing analytics and overview information.
**Actions:**
| Action | Description |
| :--------------- | :-------------------------------------------------------------- |
| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. |
**Resources:**
| Resource | Description |
| :------------ | :------------------------------------------ |
| `dashboard/*` | Represents all components of the dashboard. |

View File

@@ -1,13 +1,44 @@
import type { Request, Response } from 'express';
import type { IAuthService } from '../../services/AuthService';
import { AuthService } from '../../services/AuthService';
import { UserService } from '../../services/UserService';
import { db } from '../../database';
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
export class AuthController {
#authService: IAuthService;
#authService: AuthService;
#userService: UserService;
constructor(authService: IAuthService) {
constructor(authService: AuthService, userService: UserService) {
this.#authService = authService;
this.#userService = userService;
}
public setup = async (req: Request, res: Response): Promise<Response> => {
const { email, password, first_name, last_name } = req.body;
if (!email || !password || !first_name || !last_name) {
return res.status(400).json({ message: 'Email, password, and name are required' });
}
try {
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
const userCount = Number(userCountResult[0].count);
if (userCount > 0) {
return res.status(403).json({ message: 'Setup has already been completed.' });
}
const newUser = await this.#userService.createUser({ email, password, first_name, last_name });
const result = await this.#authService.login(email, password);
return res.status(201).json(result);
} catch (error) {
console.error('Setup error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public login = async (req: Request, res: Response): Promise<Response> => {
const { email, password } = req.body;
@@ -28,4 +59,16 @@ export class AuthController {
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public status = async (req: Request, res: Response): Promise<Response> => {
try {
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
const userCount = Number(userCountResult[0].count);
const needsSetup = userCount === 0;
return res.status(200).json({ needsSetup });
} catch (error) {
console.error('Status check error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
}

View File

@@ -0,0 +1,71 @@
import { Request, Response } from 'express';
import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { PolicyStatement } from '@open-archiver/types';
export class IamController {
#iamService: IamService;
constructor(iamService: IamService) {
this.#iamService = iamService;
}
public getRoles = async (req: Request, res: Response): Promise<void> => {
try {
const roles = await this.#iamService.getRoles();
res.status(200).json(roles);
} catch (error) {
res.status(500).json({ error: 'Failed to get roles.' });
}
};
public getRoleById = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
try {
const role = await this.#iamService.getRoleById(id);
if (role) {
res.status(200).json(role);
} else {
res.status(404).json({ error: 'Role not found.' });
}
} catch (error) {
res.status(500).json({ error: 'Failed to get role.' });
}
};
public createRole = async (req: Request, res: Response): Promise<void> => {
const { name, policy } = req.body;
if (!name || !policy) {
res.status(400).json({ error: 'Missing required fields: name and policy.' });
return;
}
for (const statement of policy) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
if (!valid) {
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
return;
}
}
try {
const role = await this.#iamService.createRole(name, policy);
res.status(201).json(role);
} catch (error) {
res.status(500).json({ error: 'Failed to create role.' });
}
};
public deleteRole = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
try {
await this.#iamService.deleteRole(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete role.' });
}
};
}

View File

@@ -5,6 +5,13 @@ import type { AuthController } from '../controllers/auth.controller';
export const createAuthRouter = (authController: AuthController): Router => {
const router = Router();
/**
* @route POST /api/v1/auth/setup
* @description Creates the initial administrator user.
* @access Public
*/
router.post('/setup', loginRateLimiter, authController.setup);
/**
* @route POST /api/v1/auth/login
* @description Authenticates a user and returns a JWT.
@@ -12,5 +19,12 @@ export const createAuthRouter = (authController: AuthController): Router => {
*/
router.post('/login', loginRateLimiter, authController.login);
/**
* @route GET /api/v1/auth/status
* @description Checks if the application has been set up.
* @access Public
*/
router.get('/status', authController.status);
return router;
};

View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/requireAuth';
import type { IamController } from '../controllers/iam.controller';
export const createIamRouter = (iamController: IamController): Router => {
const router = Router();
/**
* @route GET /api/v1/iam/roles
* @description Gets all roles.
* @access Private
*/
router.get('/roles', requireAuth, iamController.getRoles);
/**
* @route GET /api/v1/iam/roles/:id
* @description Gets a role by ID.
* @access Private
*/
router.get('/roles/:id', requireAuth, iamController.getRoleById);
/**
* @route POST /api/v1/iam/roles
* @description Creates a new role.
* @access Private
*/
router.post('/roles', requireAuth, iamController.createRole);
/**
* @route DELETE /api/v1/iam/roles/:id
* @description Deletes a role.
* @access Private
*/
router.delete('/roles/:id', requireAuth, iamController.deleteRole);
return router;
};

View File

@@ -0,0 +1,36 @@
CREATE TABLE "roles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"policies" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "roles_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_roles" (
"user_id" uuid NOT NULL,
"role_id" uuid NOT NULL,
CONSTRAINT "user_roles_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"name" text,
"password" text,
"provider" text DEFAULT 'local',
"provider_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" RENAME COLUMN "name" TO "first_name";--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "last_name" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,20 @@
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
}
]
}

View File

@@ -4,3 +4,4 @@ export * from './schema/audit-logs';
export * from './schema/compliance';
export * from './schema/custodians';
export * from './schema/ingestion-sources';
export * from './schema/users';

View File

@@ -25,11 +25,7 @@ export const archivedEmails = pgTable(
isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => {
return {
threadIdIdx: index('thread_id_idx').on(table.threadId)
};
}
(table) => [index('thread_id_idx').on(table.threadId)]
);
export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({

View File

@@ -0,0 +1,89 @@
import { relations, sql } from 'drizzle-orm';
import {
pgTable,
text,
timestamp,
uuid,
primaryKey,
jsonb
} from 'drizzle-orm/pg-core';
import type { PolicyStatement } from '@open-archiver/types';
/**
* The `users` table stores the core user information for authentication and identification.
*/
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
first_name: text('first_name'),
last_name: text('last_name'),
password: text('password'),
provider: text('provider').default('local'),
providerId: text('provider_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
/**
* The `sessions` table stores user session information for managing login state.
* It links a session to a user and records its expiration time.
*/
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', {
withTimezone: true,
mode: 'date'
}).notNull()
});
/**
* The `roles` table defines the roles that can be assigned to users.
* Each role has a name and a set of policies that define its permissions.
*/
export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
policies: jsonb('policies').$type<PolicyStatement[]>().notNull().default(sql`'[]'::jsonb`),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
/**
* The `user_roles` table is a join table that maps users to their assigned roles.
* This many-to-many relationship allows a user to have multiple roles.
*/
export const userRoles = pgTable(
'user_roles',
{
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.userId, t.roleId] })]
);
// Define relationships for Drizzle ORM
export const usersRelations = relations(users, ({ many }) => ({
userRoles: many(userRoles)
}));
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles)
}));
export const userRolesRelations = relations(userRoles, ({ one }) => ({
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id]
}),
user: one(users, {
fields: [userRoles.userId],
references: [users.id]
})
}));

View File

@@ -0,0 +1,155 @@
/**
* @file This file serves as the single source of truth for all Identity and Access Management (IAM)
* definitions within Open Archiver. Centralizing these definitions is an industry-standard practice
* that offers several key benefits:
*
* 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources
* throughout the codebase, reducing the risk of typos and inconsistencies.
* 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all
* possible permissions in the system.
* 3. **Enables Validation**: Allows for the creation of a robust validation function that can
* programmatically check if a policy statement is valid before it is saved.
* 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions
* that can be granted.
*
* The structure is inspired by AWS IAM, using a `service:operation` format for actions and a
* hierarchical, slash-separated path for resources.
*/
// ===================================================================================
// SERVICE: archive
// ===================================================================================
const ARCHIVE_ACTIONS = {
READ: 'archive:read',
SEARCH: 'archive:search',
EXPORT: 'archive:export',
} as const;
const ARCHIVE_RESOURCES = {
ALL: 'archive/all',
INGESTION_SOURCE: 'archive/ingestion-source/*',
EMAIL: 'archive/email/*',
CUSTODIAN: 'archive/custodian/*',
} as const;
// ===================================================================================
// SERVICE: ingestion
// ===================================================================================
const INGESTION_ACTIONS = {
CREATE_SOURCE: 'ingestion:createSource',
READ_SOURCE: 'ingestion:readSource',
UPDATE_SOURCE: 'ingestion:updateSource',
DELETE_SOURCE: 'ingestion:deleteSource',
MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs
} as const;
const INGESTION_RESOURCES = {
ALL: 'ingestion-source/*',
SOURCE: 'ingestion-source/{sourceId}',
} as const;
// ===================================================================================
// SERVICE: system
// ===================================================================================
const SYSTEM_ACTIONS = {
READ_SETTINGS: 'system:readSettings',
UPDATE_SETTINGS: 'system:updateSettings',
READ_USERS: 'system:readUsers',
CREATE_USER: 'system:createUser',
UPDATE_USER: 'system:updateUser',
DELETE_USER: 'system:deleteUser',
ASSIGN_ROLE: 'system:assignRole',
} as const;
const SYSTEM_RESOURCES = {
SETTINGS: 'system/settings',
USERS: 'system/users',
USER: 'system/user/{userId}',
} as const;
// ===================================================================================
// SERVICE: dashboard
// ===================================================================================
const DASHBOARD_ACTIONS = {
READ: 'dashboard:read',
} as const;
const DASHBOARD_RESOURCES = {
ALL: 'dashboard/*',
} as const;
// ===================================================================================
// EXPORTED DEFINITIONS
// ===================================================================================
/**
* A comprehensive set of all valid IAM actions in the system.
* This is used by the policy validator to ensure that any action in a policy is recognized.
*/
export const ValidActions: Set<string> = new Set([
...Object.values(ARCHIVE_ACTIONS),
...Object.values(INGESTION_ACTIONS),
...Object.values(SYSTEM_ACTIONS),
...Object.values(DASHBOARD_ACTIONS),
]);
/**
* An object containing regular expressions for validating resource formats.
* The validator uses these patterns to ensure that resource strings in a policy
* conform to the expected structure.
*
* Logic:
* - The key represents the service (e.g., 'archive').
* - The value is a RegExp that matches all valid resource formats for that service.
* - This allows for flexible validation. For example, `archive/*` is a valid pattern,
* as is `archive/email/123-abc`.
*/
export const ValidResourcePatterns = {
archive: /^archive\/(all|ingestion-source\/[^\/]+|email\/[^\/]+|custodian\/[^\/]+)$/,
ingestion: /^ingestion-source\/(\*|[^\/]+)$/,
system: /^system\/(settings|users|user\/[^\/]+)$/,
dashboard: /^dashboard\/\*$/,
};
/**
* --- How to Use These Definitions for Validation (Conceptual) ---
*
* A validator function would be created, likely in an `AuthorizationService`,
* that accepts a `PolicyStatement` object.
*
* export function isPolicyStatementValid(statement: PolicyStatement): boolean {
* // 1. Validate Actions
* for (const action of statement.Action) {
* if (action.endsWith('*')) {
* // For wildcards, check if the service prefix is valid
* const service = action.split(':')[0];
* if (!Object.keys(ValidResourcePatterns).includes(service)) {
* return false; // Invalid service
* }
* } else if (!ValidActions.has(action)) {
* return false; // Action is not in the set of known actions
* }
* }
*
* // 2. Validate Resources
* for (const resource of statement.Resource) {
* const service = resource.split('/')[0];
* const pattern = ValidResourcePatterns[service];
*
* if (!pattern || !pattern.test(resource)) {
* return false; // Resource format is invalid for the specified service
* }
* }
*
* return true;
* }
*/

View File

@@ -0,0 +1,100 @@
import type { PolicyStatement } from '@open-archiver/types';
import { ValidActions, ValidResourcePatterns } from './iam-definitions';
/**
* @class PolicyValidator
*
* This class provides a static method to validate an IAM policy statement.
* It is designed to be used before a policy is saved to the database, ensuring that
* only valid and well-formed policies are stored.
*
* The verification logic is based on the centralized definitions in `iam-definitions.ts`.
*/
export class PolicyValidator {
/**
* Validates a single policy statement to ensure its actions and resources are valid.
*
* @param {PolicyStatement} statement - The policy statement to validate.
* @returns {{valid: boolean; reason?: string}} - An object containing a boolean `valid` property
* and an optional `reason` string if validation fails.
*/
public static isValid(statement: PolicyStatement): { valid: boolean; reason: string; } {
if (!statement || !statement.Action || !statement.Resource || !statement.Effect) {
return { valid: false, reason: 'Policy statement is missing required fields.' };
}
// 1. Validate Actions
for (const action of statement.Action) {
const { valid, reason } = this.isActionValid(action);
if (!valid) {
return { valid: false, reason };
}
}
// 2. Validate Resources
for (const resource of statement.Resource) {
const { valid, reason } = this.isResourceValid(resource);
if (!valid) {
return { valid: false, reason };
}
}
return { valid: true, reason: 'valid' };
}
/**
* Checks if a single action string is valid.
*
* Logic:
* - If the action contains a wildcard (e.g., 'archive:*'), it checks if the service part
* (e.g., 'archive') is a recognized service.
* - If there is no wildcard, it checks if the full action string (e.g., 'archive:read')
* exists in the `ValidActions` set.
*
* @param {string} action - The action string to validate.
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
*/
private static isActionValid(action: string): { valid: boolean; reason: string; } {
if (action === '*') {
return { valid: true, reason: 'valid' };
}
if (action.endsWith(':*')) {
const service = action.split(':')[0];
if (service in ValidResourcePatterns) {
return { valid: true, reason: 'valid' };
}
return { valid: false, reason: `Invalid service '${service}' in action wildcard '${action}'.` };
}
if (ValidActions.has(action)) {
return { valid: true, reason: 'valid' };
}
return { valid: false, reason: `Action '${action}' is not a valid action.` };
}
/**
* Checks if a single resource string has a valid format.
*
* Logic:
* - It extracts the service name from the resource string (e.g., 'archive' from 'archive/all').
* - It looks up the corresponding regular expression for that service in `ValidResourcePatterns`.
* - It tests the resource string against the pattern. If the service does not exist or the
* pattern does not match, the resource is considered invalid.
*
* @param {string} resource - The resource string to validate.
* @returns {{valid: boolean; reason?: string}} - An object indicating validity and a reason for failure.
*/
private static isResourceValid(resource: string): { valid: boolean; reason: string; } {
const service = resource.split('/')[0];
if (service === '*') {
return { valid: true, reason: 'valid' };
}
if (service in ValidResourcePatterns) {
const pattern = ValidResourcePatterns[service as keyof typeof ValidResourcePatterns];
if (pattern.test(resource)) {
return { valid: true, reason: 'valid' };
}
return { valid: false, reason: `Resource '${resource}' does not match the expected format for the '${service}' service.` };
}
return { valid: false, reason: `Invalid service '${service}' in resource '${resource}'.` };
}
}

View File

@@ -5,8 +5,10 @@ import { IngestionController } from './api/controllers/ingestion.controller';
import { ArchivedEmailController } from './api/controllers/archived-email.controller';
import { StorageController } from './api/controllers/storage.controller';
import { SearchController } from './api/controllers/search.controller';
import { IamController } from './api/controllers/iam.controller';
import { requireAuth } from './api/middleware/requireAuth';
import { createAuthRouter } from './api/routes/auth.routes';
import { createIamRouter } from './api/routes/iam.routes';
import { createIngestionRouter } from './api/routes/ingestion.routes';
import { createArchivedEmailRouter } from './api/routes/archived-email.routes';
import { createStorageRouter } from './api/routes/storage.routes';
@@ -14,7 +16,8 @@ import { createSearchRouter } from './api/routes/search.routes';
import { createDashboardRouter } from './api/routes/dashboard.routes';
import testRouter from './api/routes/test.routes';
import { AuthService } from './services/AuthService';
import { AdminUserService } from './services/UserService';
import { UserService } from './services/UserService';
import { IamService } from './services/IamService';
import { StorageService } from './services/StorageService';
import { SearchService } from './services/SearchService';
@@ -37,15 +40,17 @@ if (!PORT_BACKEND || !JWT_SECRET || !JWT_EXPIRES_IN) {
// --- Dependency Injection Setup ---
const userService = new AdminUserService();
const userService = new UserService();
const authService = new AuthService(userService, JWT_SECRET, JWT_EXPIRES_IN);
const authController = new AuthController(authService);
const authController = new AuthController(authService, userService);
const ingestionController = new IngestionController();
const archivedEmailController = new ArchivedEmailController();
const storageService = new StorageService();
const storageController = new StorageController(storageService);
const searchService = new SearchService();
const searchController = new SearchController();
const iamService = new IamService();
const iamController = new IamController(iamService);
// --- Express App Initialization ---
const app = express();
@@ -60,7 +65,9 @@ const archivedEmailRouter = createArchivedEmailRouter(archivedEmailController, a
const storageRouter = createStorageRouter(storageController, authService);
const searchRouter = createSearchRouter(searchController, authService);
const dashboardRouter = createDashboardRouter(authService);
const iamRouter = createIamRouter(iamController);
app.use('/v1/auth', authRouter);
app.use('/v1/iam', iamRouter);
app.use('/v1/ingestion-sources', ingestionRouter);
app.use('/v1/archived-emails', archivedEmailRouter);
app.use('/v1/storage', storageRouter);

View File

@@ -1,38 +1,23 @@
import { compare, hash } from 'bcryptjs';
import type { SignJWT, jwtVerify } from 'jose';
import type { AuthTokenPayload, User, LoginResponse } from '@open-archiver/types';
import { compare } from 'bcryptjs';
import { SignJWT, jwtVerify } from 'jose';
import type { AuthTokenPayload, LoginResponse } from '@open-archiver/types';
import { UserService } from './UserService';
import { db } from '../database';
import * as schema from '../database/schema';
import { eq } from 'drizzle-orm';
// This interface defines the contract for a service that manages users.
// The AuthService will depend on this abstraction, not a concrete implementation.
export interface IUserService {
findByEmail(email: string): Promise<User | null>;
}
// This interface defines the contract for our AuthService.
export interface IAuthService {
verifyPassword(password: string, hash: string): Promise<boolean>;
login(email: string, password: string): Promise<LoginResponse | null>;
verifyToken(token: string): Promise<AuthTokenPayload | null>;
}
export class AuthService implements IAuthService {
#userService: IUserService;
export class AuthService {
#userService: UserService;
#jwtSecret: Uint8Array;
#jwtExpiresIn: string;
#jose: Promise<{ SignJWT: typeof SignJWT; jwtVerify: typeof jwtVerify; }>;
constructor(userService: IUserService, jwtSecret: string, jwtExpiresIn: string) {
constructor(userService: UserService, jwtSecret: string, jwtExpiresIn: string) {
this.#userService = userService;
this.#jwtSecret = new TextEncoder().encode(jwtSecret);
this.#jwtExpiresIn = jwtExpiresIn;
this.#jose = import('jose');
}
#hashPassword(password: string): Promise<string> {
return hash(password, 10);
}
public verifyPassword(password: string, hash: string): Promise<boolean> {
public async verifyPassword(password: string, hash: string): Promise<boolean> {
return compare(password, hash);
}
@@ -40,7 +25,6 @@ export class AuthService implements IAuthService {
if (!payload.sub) {
throw new Error('JWT payload must have a subject (sub) claim.');
}
const { SignJWT } = await this.#jose;
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
@@ -52,22 +36,31 @@ export class AuthService implements IAuthService {
public async login(email: string, password: string): Promise<LoginResponse | null> {
const user = await this.#userService.findByEmail(email);
if (!user) {
return null; // User not found
if (!user || !user.password) {
return null; // User not found or password not set
}
const isPasswordValid = await this.verifyPassword(password, user.passwordHash);
const isPasswordValid = await this.verifyPassword(password, user.password);
if (!isPasswordValid) {
return null; // Invalid password
}
const { passwordHash, ...userWithoutPassword } = user;
const userRoles = await db.query.userRoles.findMany({
where: eq(schema.userRoles.userId, user.id),
with: {
role: true
}
});
const roles = userRoles.map(ur => ur.role.name);
const { password: _, ...userWithoutPassword } = user;
const accessToken = await this.#generateAccessToken({
sub: user.id,
email: user.email,
role: user.role,
roles: roles,
});
return { accessToken, user: userWithoutPassword };
@@ -75,7 +68,6 @@ export class AuthService implements IAuthService {
public async verifyToken(token: string): Promise<AuthTokenPayload | null> {
try {
const { jwtVerify } = await this.#jose;
const { payload } = await jwtVerify<AuthTokenPayload>(token, this.#jwtSecret);
return payload;
} catch (error) {

View File

@@ -0,0 +1,24 @@
import { db } from '../database';
import { roles } from '../database/schema/users';
import type { Role, PolicyStatement } from '@open-archiver/types';
import { eq } from 'drizzle-orm';
export class IamService {
public async getRoles(): Promise<Role[]> {
return db.select().from(roles);
}
public async getRoleById(id: string): Promise<Role | undefined> {
const [role] = await db.select().from(roles).where(eq(roles.id, id));
return role;
}
public async createRole(name: string, policy: PolicyStatement[]): Promise<Role> {
const [role] = await db.insert(roles).values({ name, policies: policy }).returning();
return role;
}
public async deleteRole(id: string): Promise<void> {
await db.delete(roles).where(eq(roles.id, id));
}
}

View File

@@ -1,31 +1,79 @@
import { db } from '../database';
import * as schema from '../database/schema';
import { and, eq, asc, sql } from 'drizzle-orm';
import { hash } from 'bcryptjs';
import type { User } from '@open-archiver/types';
import type { IUserService } from './AuthService';
import type { PolicyStatement, User } from '@open-archiver/types';
import { PolicyValidator } from '../iam-policy/policy-validator';
// This is a mock implementation of the IUserService.
// Later on, this service would interact with a database.
export class AdminUserService implements IUserService {
#users: User[] = [];
constructor() {
// Immediately seed the user when the service is instantiated.
this.seed();
}
// use .env admin user
private async seed() {
const passwordHash = await hash(process.env.ADMIN_PASSWORD as string, 10);
this.#users.push({
id: '1',
email: process.env.ADMIN_EMAIL as string,
role: 'Super Administrator',
passwordHash: passwordHash,
export class UserService {
/**
* Finds a user by their email address.
* @param email The email address of the user to find.
* @returns The user object if found, otherwise null.
*/
public async findByEmail(email: string): Promise<(typeof schema.users.$inferSelect) | null> {
const user = await db.query.users.findFirst({
where: eq(schema.users.email, email)
});
}
public async findByEmail(email: string): Promise<User | null> {
// once user service is ready, this would be a database query.
const user = this.#users.find(u => u.email === email);
return user || null;
}
/**
* Finds a user by their ID.
* @param id The ID of the user to find.
* @returns The user object if found, otherwise null.
*/
public async findById(id: string): Promise<(typeof schema.users.$inferSelect) | null> {
const user = await db.query.users.findFirst({
where: eq(schema.users.id, id)
});
return user || null;
}
/**
* Creates a new user in the database.
* The first user created will be assigned the 'Super Admin' role.
* @param userDetails The details of the user to create.
* @returns The newly created user object.
*/
public async createUser(userDetails: Pick<User, 'email' | 'first_name' | 'last_name'> & { password?: string; }): Promise<(typeof schema.users.$inferSelect)> {
const { email, first_name, last_name, password } = userDetails;
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
const isFirstUser = Number(userCountResult[0].count) === 0;
const hashedPassword = password ? await hash(password, 10) : undefined;
const newUser = await db.insert(schema.users).values({
email,
first_name,
last_name,
password: hashedPassword,
}).returning();
if (isFirstUser) {
let superAdminRole = await db.query.roles.findFirst({
where: eq(schema.roles.name, 'Super Admin')
});
if (!superAdminRole) {
const suerAdminPolicies: PolicyStatement[] = [{
Effect: 'Allow',
Action: ['*'],
Resource: ['*']
}];
superAdminRole = (await db.insert(schema.roles).values({
name: 'Super Admin',
policies: suerAdminPolicies
}).returning())[0];
}
await db.insert(schema.userRoles).values({
userId: newUser[0].id,
roleId: superAdminRole.id
});
}
return newUser[0];
}
}

View File

@@ -1,9 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import 'dotenv/config';
import { api } from '$lib/server/api';
export const load: LayoutServerLoad = async (event) => {
const { locals, url } = event;
try {
const response = await api('/auth/status', event);
const { needsSetup } = await response.json();
if (needsSetup && url.pathname !== '/setup') {
throw redirect(307, '/setup');
}
if (!needsSetup && url.pathname === '/setup') {
throw redirect(307, '/signin');
}
} catch (error) {
throw error;
}
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
accessToken: locals.accessToken,

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from "./$types";
export const load = (async (event) => { }) satisfies PageServerLoad;

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { api } from '$lib/api.client';
import { authStore } from '$lib/stores/auth.store';
import { setAlert } from '$lib/components/custom/alert/alert-state.svelte';
let first_name = '';
let last_name = '';
let email = '';
let password = '';
let isLoading = false;
async function handleSubmit() {
isLoading = true;
try {
const response = await api('/auth/setup', {
method: 'POST',
body: JSON.stringify({ first_name, last_name, email, password })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'An unknown error occurred.');
}
const { accessToken, user } = await response.json();
authStore.login(accessToken, user);
goto('/dashboard');
} catch (err: any) {
setAlert({
type: 'error',
title: 'Setup Failed',
message: err.message,
duration: 5000,
show: true
});
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Setup - Open Archiver</title>
<meta name="description" content="Set up the initial administrator account for Open Archiver." />
</svelte:head>
<div
class="flex min-h-screen flex-col items-center justify-center space-y-16 bg-gray-100 dark:bg-gray-900"
>
<div>
<a
href="https://openarchiver.com/"
target="_blank"
class="flex flex-row items-center gap-2 font-bold"
>
<img src="/logos/logo-sq.svg" alt="OpenArchiver Logo" class="h-16 w-16" />
<span class="text-2xl">Open Archiver</span>
</a>
</div>
<Card.Root class="w-full max-w-md">
<Card.Header class="space-y-1">
<Card.Title class="text-2xl">Welcome</Card.Title>
<Card.Description>Create the first administrator account to get started.</Card.Description>
</Card.Header>
<Card.Content class="grid gap-4">
<form on:submit|preventDefault={handleSubmit} class="grid gap-4">
<div class="grid gap-2">
<Label for="first_name">First name</Label>
<Input
id="first_name"
type="text"
placeholder="First name"
bind:value={first_name}
required
/>
</div>
<div class="grid gap-2">
<Label for="last_name">Last name</Label>
<Input
id="last_name"
type="text"
placeholder="Last name"
bind:value={last_name}
required
/>
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" bind:value={email} required />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input id="password" type="password" bind:value={password} required />
</div>
<Button type="submit" class="w-full" disabled={isLoading}>
{#if isLoading}
<span>Creating Account...</span>
{:else}
<span>Create Account</span>
{/if}
</Button>
</form>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from "./$types";
export const load = (async (event) => {
const { locals } = event;
if (locals.user) {
throw redirect(307, '/dashboard');
}
}) satisfies PageServerLoad;

View File

@@ -11,9 +11,9 @@ export interface AuthTokenPayload extends JWTPayload {
*/
email: string;
/**
* The user's role, used for authorization.
* The user's assigned roles, which determines their permissions.
*/
role: User['role'];
roles: string[];
}
/**
@@ -27,5 +27,5 @@ export interface LoginResponse {
/**
* The authenticated user's information.
*/
user: Omit<User, 'passwordHash'>;
user: Omit<User, 'password'>;
}

View File

@@ -0,0 +1,9 @@
export type Action = string;
export type Resource = string;
export interface PolicyStatement {
Effect: 'Allow' | 'Deny';
Action: Action[];
Resource: Resource[];
}

View File

@@ -6,3 +6,4 @@ export * from './email.types';
export * from './archived-emails.types';
export * from './search.types';
export * from './dashboard.types';
export * from './iam.types';

View File

@@ -1,26 +1,34 @@
/**
* Defines the possible roles a user can have within the system.
*/
export type UserRole = 'Super Administrator' | 'Auditor/Compliance Officer' | 'End User';
import { PolicyStatement } from './iam.types';
/**
* Represents a user account in the system.
* This is the core user object that will be stored in the database.
*/
export interface User {
/**
* The unique identifier for the user.
*/
id: string;
/**
* The user's email address, used for login.
*/
first_name: string | null;
last_name: string | null;
email: string;
/**
* The user's assigned role, which determines their permissions.
*/
role: UserRole;
/**
* The hashed password for the user. This should never be exposed to the client.
*/
passwordHash: string;
}
/**
* Represents a user's session.
* This is used to track a user's login status.
*/
export interface Session {
id: string;
userId: string;
expiresAt: Date;
}
/**
* Defines a role that can be assigned to users.
* Roles are used to group a set of permissions together.
*/
export interface Role {
id: string;
name: string;
policies: PolicyStatement[];
createdAt: Date;
updatedAt: Date;
}