mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Migrating user service to database, sunsetting admin user
This commit is contained in:
111
docs/developer-guides/iam-policies.md
Normal file
111
docs/developer-guides/iam-policies.md
Normal 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. |
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
71
packages/backend/src/api/controllers/iam.controller.ts
Normal file
71
packages/backend/src/api/controllers/iam.controller.ts
Normal 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.' });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
37
packages/backend/src/api/routes/iam.routes.ts
Normal file
37
packages/backend/src/api/routes/iam.routes.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" RENAME COLUMN "name" TO "first_name";--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "last_name" text;
|
||||
1087
packages/backend/src/database/migrations/meta/0010_snapshot.json
Normal file
1087
packages/backend/src/database/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1093
packages/backend/src/database/migrations/meta/0011_snapshot.json
Normal file
1093
packages/backend/src/database/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
89
packages/backend/src/database/schema/users.ts
Normal file
89
packages/backend/src/database/schema/users.ts
Normal 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]
|
||||
})
|
||||
}));
|
||||
155
packages/backend/src/iam-policy/iam-definitions.ts
Normal file
155
packages/backend/src/iam-policy/iam-definitions.ts
Normal 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;
|
||||
* }
|
||||
*/
|
||||
100
packages/backend/src/iam-policy/policy-validator.ts
Normal file
100
packages/backend/src/iam-policy/policy-validator.ts
Normal 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}'.` };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
24
packages/backend/src/services/IamService.ts
Normal file
24
packages/backend/src/services/IamService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
packages/frontend/src/routes/setup/+page.server.ts
Normal file
5
packages/frontend/src/routes/setup/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
|
||||
export const load = (async (event) => { }) satisfies PageServerLoad;
|
||||
111
packages/frontend/src/routes/setup/+page.svelte
Normal file
111
packages/frontend/src/routes/setup/+page.svelte
Normal 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>
|
||||
11
packages/frontend/src/routes/signin/+page.server.ts
Normal file
11
packages/frontend/src/routes/signin/+page.server.ts
Normal 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;
|
||||
@@ -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'>;
|
||||
}
|
||||
|
||||
9
packages/types/src/iam.types.ts
Normal file
9
packages/types/src/iam.types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Action = string;
|
||||
|
||||
export type Resource = string;
|
||||
|
||||
export interface PolicyStatement {
|
||||
Effect: 'Allow' | 'Deny';
|
||||
Action: Action[];
|
||||
Resource: Resource[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user