fase(9): auth module with casl rbac and session management

This commit is contained in:
debian
2026-03-05 09:57:49 -05:00
parent 39a5e41f75
commit 7526a5bc15
77 changed files with 3588 additions and 41 deletions

View File

@@ -5,10 +5,61 @@ import { Router } from 'express';
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { ServerDependencies } from './server';
import { RegisterCommand } from '../modules/auth/application/commands/RegisterCommand';
import { LoginCommand } from '../modules/auth/application/commands/LoginCommand';
import { CreateOrganizationCommand } from '../modules/auth/application/commands/CreateOrganizationCommand';
import { InviteMemberCommand } from '../modules/auth/application/commands/InviteMemberCommand';
import { CreateApiKeyCommand } from '../modules/auth/application/commands/CreateApiKeyCommand';
import { GetUserQuery } from '../modules/auth/application/queries/GetUserQuery';
import { ListOrgMembersQuery } from '../modules/auth/application/queries/ListOrgMembersQuery';
import { IUserRepository } from '../modules/auth/domain/ports/IUserRepository';
import { ISessionRepository } from '../modules/auth/domain/ports/ISessionRepository';
import { IApiKeyRepository } from '../modules/auth/domain/ports/IApiKeyRepository';
export interface AuthControllerDeps {
registerCommand: RegisterCommand;
loginCommand: LoginCommand;
createOrgCommand: CreateOrganizationCommand;
inviteMemberCommand: InviteMemberCommand;
createApiKeyCommand: CreateApiKeyCommand;
getUserQuery: GetUserQuery;
listOrgMembersQuery: ListOrgMembersQuery;
sessionRepository: ISessionRepository;
apiKeyRepository: IApiKeyRepository;
userRepository: IUserRepository;
}
export function createRouter(deps: ServerDependencies): Router {
const router = Router();
const { authDeps } = deps;
// Auth routes — public (no auth middleware)
router.use(
'/auth',
createAuthController(
authDeps.registerCommand,
authDeps.loginCommand,
authDeps.createOrgCommand,
authDeps.inviteMemberCommand,
authDeps.createApiKeyCommand,
authDeps.getUserQuery,
authDeps.listOrgMembersQuery,
authDeps.sessionRepository,
authDeps.apiKeyRepository,
authDeps.userRepository
)
);
// Apply auth middleware to all routes below
const authMiddleware = createAuthMiddleware(
authDeps.userRepository,
authDeps.sessionRepository,
authDeps.apiKeyRepository
);
router.use(authMiddleware);
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
router.use('/findings', createFindingsRouter(deps.findingsDeps));

View File

@@ -6,6 +6,7 @@ import express, { Express, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import cookieParser from 'cookie-parser';
import { Kysely } from 'kysely';
import { AppConfig } from '../shared/infrastructure/Config';
import { Logger } from '../shared/infrastructure/Logger';
@@ -17,6 +18,7 @@ import { createRouter } from './router';
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
import { AuthControllerDeps } from './router';
export interface ServerDependencies {
config: AppConfig;
@@ -25,6 +27,7 @@ export interface ServerDependencies {
crawlingDeps: CrawlingControllerDeps;
findingsDeps: FindingsControllerDeps;
fuzzingDeps: FuzzingControllerDeps;
authDeps: AuthControllerDeps;
}
export function createServer(deps: ServerDependencies): Express {
@@ -59,8 +62,9 @@ export function createServer(deps: ServerDependencies): Express {
}),
);
// 5. Body parsing
// 5. Body parsing + cookies
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
// 6. Health endpoints — no auth required
app.get('/health/live', (_req: Request, res: Response) => {

View File

@@ -0,0 +1,86 @@
import { Kysely } from 'kysely';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('email', 'text', (col) => col.notNull().unique())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('password_hash', 'text', (col) => col.notNull())
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('org_id', 'text')
.addColumn('created_at', 'integer', (col) => col.notNull())
.addColumn('updated_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('organizations')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('slug', 'text', (col) => col.notNull().unique())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('org_members')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('org_id', 'text', (col) => col.notNull().references('organizations.id'))
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
.addColumn('joined_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('api_keys')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('org_id', 'text', (col) => col.notNull())
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('key_hash', 'text', (col) => col.notNull().unique())
.addColumn('key_prefix', 'text', (col) => col.notNull())
.addColumn('permissions', 'text', (col) => col.notNull().defaultTo('["member"]'))
.addColumn('expires_at', 'integer')
.addColumn('last_used_at', 'integer')
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createTable('auth_sessions')
.ifNotExists()
.addColumn('id', 'text', (col) => col.primaryKey())
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
.addColumn('token', 'text', (col) => col.notNull().unique())
.addColumn('expires_at', 'integer', (col) => col.notNull())
.addColumn('created_at', 'integer', (col) => col.notNull())
.execute();
await db.schema
.createIndex('idx_auth_sessions_token')
.ifNotExists()
.on('auth_sessions')
.columns(['token'])
.execute();
await db.schema
.createIndex('idx_users_email')
.ifNotExists()
.on('users')
.columns(['email'])
.execute();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_users_email').ifExists().execute();
await db.schema.dropIndex('idx_auth_sessions_token').ifExists().execute();
await db.schema.dropTable('auth_sessions').ifExists().execute();
await db.schema.dropTable('api_keys').ifExists().execute();
await db.schema.dropTable('org_members').ifExists().execute();
await db.schema.dropTable('organizations').ifExists().execute();
await db.schema.dropTable('users').ifExists().execute();
}

View File

@@ -37,6 +37,20 @@ import { RunFuzzCommand } from './modules/fuzzing/application/commands/RunFuzzCo
import { OnActionExecuted } from './modules/fuzzing/application/event-handlers/OnActionExecuted';
import { InMemoryFuzzSessionRepository } from './modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository';
// Auth module
import { KyselyUserRepository } from './modules/auth/infrastructure/repositories/KyselyUserRepository';
import { KyselyOrganizationRepository } from './modules/auth/infrastructure/repositories/KyselyOrganizationRepository';
import { KyselyApiKeyRepository } from './modules/auth/infrastructure/repositories/KyselyApiKeyRepository';
import { KyselySessionRepository } from './modules/auth/infrastructure/repositories/KyselySessionRepository';
import { RegisterCommand } from './modules/auth/application/commands/RegisterCommand';
import { LoginCommand } from './modules/auth/application/commands/LoginCommand';
import { CreateOrganizationCommand } from './modules/auth/application/commands/CreateOrganizationCommand';
import { InviteMemberCommand } from './modules/auth/application/commands/InviteMemberCommand';
import { CreateApiKeyCommand } from './modules/auth/application/commands/CreateApiKeyCommand';
import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
// Job queue
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
@@ -97,7 +111,21 @@ async function bootstrap(): Promise<void> {
const onActionExecuted = new OnActionExecuted(runFuzz);
eventBus.subscribe('crawling.action_executed', onActionExecuted);
// 10. HTTP server
// 10. Auth module
const userRepo = new KyselyUserRepository(db);
const orgRepo = new KyselyOrganizationRepository(db);
const apiKeyRepo = new KyselyApiKeyRepository(db);
const authSessionRepo = new KyselySessionRepository(db);
const registerCommand = new RegisterCommand(userRepo, eventBus, hashPassword);
const loginCommand = new LoginCommand(userRepo, authSessionRepo, eventBus, verifyPassword);
const createOrgCommand = new CreateOrganizationCommand(orgRepo, userRepo, eventBus);
const inviteMemberCommand = new InviteMemberCommand(orgRepo, userRepo, eventBus);
const createApiKeyCommand = new CreateApiKeyCommand(apiKeyRepo, userRepo);
const getUserQuery = new GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo);
// 11. HTTP server
const app = createServer({
config,
logger,
@@ -105,6 +133,18 @@ async function bootstrap(): Promise<void> {
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
authDeps: {
registerCommand,
loginCommand,
createOrgCommand,
inviteMemberCommand,
createApiKeyCommand,
getUserQuery,
listOrgMembersQuery,
sessionRepository: authSessionRepo,
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
});
const httpServer = http.createServer(app);

View File

@@ -0,0 +1,62 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { ApiKey } from '../../domain/entities/ApiKey';
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { createHash, randomBytes } from 'crypto';
export interface CreateApiKeyRequest {
userId: string;
orgId: string;
name: string;
permissions?: string[];
expiresAt?: Date;
}
export interface CreateApiKeyResponse {
id: string;
key: string;
keyPrefix: string;
name: string;
}
export class CreateApiKeyCommand implements UseCase<CreateApiKeyRequest, CreateApiKeyResponse, string> {
constructor(
private readonly apiKeyRepository: IApiKeyRepository,
private readonly userRepository: IUserRepository
) {}
async execute(request: CreateApiKeyRequest): Promise<Result<CreateApiKeyResponse, string>> {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return Err('User not found');
}
if (!request.name.trim()) {
return Err('API key name is required');
}
const rawKey = `abe_${randomBytes(32).toString('hex')}`;
const keyHash = createHash('sha256').update(rawKey).digest('hex');
const keyPrefix = rawKey.substring(0, 12);
const apiKey = ApiKey.create({
userId: request.userId,
orgId: request.orgId,
name: request.name.trim(),
keyHash,
keyPrefix,
permissions: request.permissions ?? ['member'],
expiresAt: request.expiresAt,
});
await this.apiKeyRepository.save(apiKey);
return Ok({
id: apiKey.id.toString(),
key: rawKey,
keyPrefix,
name: apiKey.name,
});
}
}

View File

@@ -0,0 +1,68 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { EventBus } from '../../../../shared/application/EventBus';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { Organization } from '../../domain/entities/Organization';
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { randomUUID } from 'crypto';
export interface CreateOrganizationRequest {
name: string;
ownerId: string;
}
export interface CreateOrganizationResponse {
orgId: string;
name: string;
slug: string;
}
export class CreateOrganizationCommand implements UseCase<CreateOrganizationRequest, CreateOrganizationResponse, string> {
constructor(
private readonly orgRepository: IOrganizationRepository,
private readonly userRepository: IUserRepository,
private readonly eventBus: EventBus
) {}
async execute(request: CreateOrganizationRequest): Promise<Result<CreateOrganizationResponse, string>> {
const user = await this.userRepository.findById(request.ownerId);
if (!user) {
return Err('User not found');
}
const slug = Organization.slugify(request.name);
if (!slug) {
return Err('Invalid organization name');
}
const existing = await this.orgRepository.findBySlug(slug);
if (existing) {
return Err('Organization name already taken');
}
const org = Organization.create({ name: request.name, slug });
await this.orgRepository.save(org);
await this.orgRepository.addMember({
id: randomUUID(),
orgId: org.id.toString(),
userId: request.ownerId,
role: 'owner',
joinedAt: new Date(),
});
user.assignToOrg(org.id.toString());
await this.userRepository.save(user);
for (const event of org.domainEvents) {
await this.eventBus.publish(event);
}
org.clearEvents();
return Ok({
orgId: org.id.toString(),
name: org.name,
slug: org.slug,
});
}
}

View File

@@ -0,0 +1,83 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { EventBus } from '../../../../shared/application/EventBus';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { Email } from '../../domain/value-objects/Email';
import { Role } from '../../domain/value-objects/Role';
import { MemberInvited } from '../../domain/events/MemberInvited';
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { randomUUID } from 'crypto';
export interface InviteMemberRequest {
orgId: string;
inviterUserId: string;
email: string;
role: string;
}
export interface InviteMemberResponse {
memberId: string;
email: string;
role: string;
}
export class InviteMemberCommand implements UseCase<InviteMemberRequest, InviteMemberResponse, string> {
constructor(
private readonly orgRepository: IOrganizationRepository,
private readonly userRepository: IUserRepository,
private readonly eventBus: EventBus
) {}
async execute(request: InviteMemberRequest): Promise<Result<InviteMemberResponse, string>> {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return Err('Organization not found');
}
let email: Email;
try {
email = Email.create(request.email);
} catch {
return Err('Invalid email address');
}
let role: Role;
try {
role = Role.create(request.role);
} catch {
return Err('Invalid role');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return Err('User with this email not found. They must register first.');
}
const existing = await this.orgRepository.getMember(request.orgId, user.id.toString());
if (existing) {
return Err('User is already a member of this organization');
}
const memberId = randomUUID();
await this.orgRepository.addMember({
id: memberId,
orgId: request.orgId,
userId: user.id.toString(),
role: role.value,
joinedAt: new Date(),
});
const event = new MemberInvited(request.orgId, {
email: email.value,
role: role.value,
inviterUserId: request.inviterUserId,
});
await this.eventBus.publish(event);
return Ok({
memberId,
email: email.value,
role: role.value,
});
}
}

View File

@@ -0,0 +1,77 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { EventBus } from '../../../../shared/application/EventBus';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { Email } from '../../domain/value-objects/Email';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { ISessionRepository, AuthSession } from '../../domain/ports/ISessionRepository';
import { UserLoggedIn } from '../../domain/events/UserLoggedIn';
import { randomUUID } from 'crypto';
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
userId: string;
sessionToken: string;
expiresAt: Date;
role: string;
name: string;
}
export class LoginCommand implements UseCase<LoginRequest, LoginResponse, string> {
constructor(
private readonly userRepository: IUserRepository,
private readonly sessionRepository: ISessionRepository,
private readonly eventBus: EventBus,
private readonly verifyPassword: (password: string, hash: string) => Promise<boolean>,
private readonly sessionMaxAgeSeconds: number = 7 * 24 * 60 * 60
) {}
async execute(request: LoginRequest): Promise<Result<LoginResponse, string>> {
let email: Email;
try {
email = Email.create(request.email);
} catch {
return Err('Invalid credentials');
}
const user = await this.userRepository.findByEmail(email.value);
if (!user) {
return Err('Invalid credentials');
}
const valid = await this.verifyPassword(request.password, user.passwordHash);
if (!valid) {
return Err('Invalid credentials');
}
const token = randomUUID();
const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000);
const session: AuthSession = {
id: randomUUID(),
userId: user.id.toString(),
token,
expiresAt,
createdAt: new Date(),
};
await this.sessionRepository.save(session);
const event = new UserLoggedIn(user.id.toString(), {
email: user.email.value,
sessionId: session.id,
});
await this.eventBus.publish(event);
return Ok({
userId: user.id.toString(),
sessionToken: token,
expiresAt,
role: user.role.value,
name: user.name,
});
}
}

View File

@@ -0,0 +1,71 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { EventBus } from '../../../../shared/application/EventBus';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { User } from '../../domain/entities/User';
import { Email } from '../../domain/value-objects/Email';
import { Role } from '../../domain/value-objects/Role';
import { IUserRepository } from '../../domain/ports/IUserRepository';
export interface RegisterRequest {
email: string;
password: string;
name: string;
role?: string;
}
export interface RegisterResponse {
userId: string;
email: string;
name: string;
role: string;
}
export class RegisterCommand implements UseCase<RegisterRequest, RegisterResponse, string> {
constructor(
private readonly userRepository: IUserRepository,
private readonly eventBus: EventBus,
private readonly hashPassword: (password: string) => Promise<string>
) {}
async execute(request: RegisterRequest): Promise<Result<RegisterResponse, string>> {
let email: Email;
try {
email = Email.create(request.email);
} catch {
return Err('Invalid email address');
}
const existing = await this.userRepository.findByEmail(email.value);
if (existing) {
return Err('Email already registered');
}
if (request.password.length < 8) {
return Err('Password must be at least 8 characters');
}
let role: Role;
try {
role = request.role ? Role.create(request.role) : Role.member();
} catch {
return Err('Invalid role');
}
const passwordHash = await this.hashPassword(request.password);
const user = User.create({ email, name: request.name, passwordHash, role });
await this.userRepository.save(user);
for (const event of user.domainEvents) {
await this.eventBus.publish(event);
}
user.clearEvents();
return Ok({
userId: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
});
}
}

View File

@@ -0,0 +1,96 @@
import { Request, Response, NextFunction } from 'express';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { ISessionRepository } from '../../domain/ports/ISessionRepository';
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
import { createHash } from 'crypto';
export interface AuthenticatedUser {
id: string;
email: string;
name: string;
role: string;
orgId?: string;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
user?: AuthenticatedUser;
}
}
}
export function createAuthMiddleware(
userRepository: IUserRepository,
sessionRepository: ISessionRepository,
apiKeyRepository: IApiKeyRepository
) {
return async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
// 1. Check session cookie
const sessionToken = req.cookies?.['abe_session'];
if (sessionToken) {
const session = await sessionRepository.findByToken(sessionToken);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 2. Check Bearer JWT (session token in header)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const session = await sessionRepository.findByToken(token);
if (session && session.expiresAt > new Date()) {
const user = await userRepository.findById(session.userId);
if (user) {
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
// 3. Check API key
const apiKeyHeader = req.headers['x-abe-api-key'];
if (apiKeyHeader && typeof apiKeyHeader === 'string') {
const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex');
const apiKey = await apiKeyRepository.findByHash(keyHash);
if (apiKey && !apiKey.isExpired()) {
const user = await userRepository.findById(apiKey.userId);
if (user) {
await apiKeyRepository.updateLastUsed(apiKey.id.toString(), new Date());
req.user = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
};
return next();
}
}
}
res.status(401).json({ error: 'Unauthorized' });
} catch {
res.status(401).json({ error: 'Unauthorized' });
}
};
}

View File

@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import { defineAbilityFor } from '../../infrastructure/casl/AbilityFactory';
import { RoleValue } from '../../domain/value-objects/Role';
import { PermissionSubject } from '../../domain/value-objects/Permission';
export function requirePermission(action: string, subject: PermissionSubject) {
return function rbacMiddleware(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const ability = defineAbilityFor(req.user.role as RoleValue);
if (!ability.can(action, subject)) {
res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to ${action} ${subject}`,
});
return;
}
next();
};
}

View File

@@ -0,0 +1,35 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { IUserRepository } from '../../domain/ports/IUserRepository';
export interface GetUserRequest {
userId: string;
}
export interface GetUserResponse {
id: string;
email: string;
name: string;
role: string;
orgId?: string;
createdAt: Date;
}
export class GetUserQuery implements UseCase<GetUserRequest, GetUserResponse, string> {
constructor(private readonly userRepository: IUserRepository) {}
async execute(request: GetUserRequest): Promise<Result<GetUserResponse, string>> {
const user = await this.userRepository.findById(request.userId);
if (!user) {
return Err('User not found');
}
return Ok({
id: user.id.toString(),
email: user.email.value,
name: user.name,
role: user.role.value,
orgId: user.orgId,
createdAt: user.createdAt,
});
}
}

View File

@@ -0,0 +1,55 @@
import { UseCase } from '../../../../shared/application/UseCase';
import { Result, Ok, Err } from '../../../../shared/domain/Result';
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
import { IUserRepository } from '../../domain/ports/IUserRepository';
export interface ListOrgMembersRequest {
orgId: string;
}
export interface OrgMemberDTO {
id: string;
userId: string;
email: string;
name: string;
role: string;
joinedAt: Date;
}
export interface ListOrgMembersResponse {
members: OrgMemberDTO[];
total: number;
}
export class ListOrgMembersQuery implements UseCase<ListOrgMembersRequest, ListOrgMembersResponse, string> {
constructor(
private readonly orgRepository: IOrganizationRepository,
private readonly userRepository: IUserRepository
) {}
async execute(request: ListOrgMembersRequest): Promise<Result<ListOrgMembersResponse, string>> {
const org = await this.orgRepository.findById(request.orgId);
if (!org) {
return Err('Organization not found');
}
const members = await this.orgRepository.listMembers(request.orgId);
const dtos: OrgMemberDTO[] = [];
for (const member of members) {
const user = await this.userRepository.findById(member.userId);
if (user) {
dtos.push({
id: member.id,
userId: member.userId,
email: user.email.value,
name: user.name,
role: member.role,
joinedAt: member.joinedAt,
});
}
}
return Ok({ members: dtos, total: dtos.length });
}
}

View File

@@ -0,0 +1,50 @@
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export interface ApiKeyProps {
userId: string;
orgId: string;
name: string;
keyHash: string;
keyPrefix: string;
permissions: string[];
expiresAt?: Date;
lastUsedAt?: Date;
createdAt: Date;
}
export class ApiKey extends AggregateRoot<ApiKeyProps> {
static create(props: Omit<ApiKeyProps, 'createdAt' | 'lastUsedAt'>, id?: UniqueId): ApiKey {
const keyId = id ?? UniqueId.create();
return new ApiKey(
{
...props,
createdAt: new Date(),
},
keyId
);
}
static reconstitute(props: ApiKeyProps, id: UniqueId): ApiKey {
return new ApiKey(props, id);
}
get userId(): string { return this.props.userId; }
get orgId(): string { return this.props.orgId; }
get name(): string { return this.props.name; }
get keyHash(): string { return this.props.keyHash; }
get keyPrefix(): string { return this.props.keyPrefix; }
get permissions(): string[] { return this.props.permissions; }
get expiresAt(): Date | undefined { return this.props.expiresAt; }
get lastUsedAt(): Date | undefined { return this.props.lastUsedAt; }
get createdAt(): Date { return this.props.createdAt; }
isExpired(): boolean {
if (!this.props.expiresAt) return false;
return new Date() > this.props.expiresAt;
}
markUsed(): void {
this.props.lastUsedAt = new Date();
}
}

View File

@@ -0,0 +1,44 @@
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { OrgCreated } from '../events/OrgCreated';
export interface OrganizationProps {
name: string;
slug: string;
createdAt: Date;
}
export class Organization extends AggregateRoot<OrganizationProps> {
static create(props: Omit<OrganizationProps, 'createdAt'>, id?: UniqueId): Organization {
const orgId = id ?? UniqueId.create();
const org = new Organization(
{
...props,
createdAt: new Date(),
},
orgId
);
org.addDomainEvent(
new OrgCreated(orgId.toString(), {
name: props.name,
slug: props.slug,
})
);
return org;
}
static reconstitute(props: OrganizationProps, id: UniqueId): Organization {
return new Organization(props, id);
}
static slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
get name(): string { return this.props.name; }
get slug(): string { return this.props.slug; }
get createdAt(): Date { return this.props.createdAt; }
}

View File

@@ -0,0 +1,60 @@
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { Email } from '../value-objects/Email';
import { Role } from '../value-objects/Role';
import { UserCreated } from '../events/UserCreated';
export interface UserProps {
email: Email;
name: string;
passwordHash: string;
role: Role;
orgId?: string;
createdAt: Date;
updatedAt: Date;
}
export class User extends AggregateRoot<UserProps> {
static create(props: Omit<UserProps, 'createdAt' | 'updatedAt'>, id?: UniqueId): User {
const userId = id ?? UniqueId.create();
const now = new Date();
const user = new User(
{
...props,
createdAt: now,
updatedAt: now,
},
userId
);
user.addDomainEvent(
new UserCreated(userId.toString(), {
email: props.email.value,
name: props.name,
role: props.role.value,
})
);
return user;
}
static reconstitute(props: UserProps, id: UniqueId): User {
return new User(props, id);
}
get email(): Email { return this.props.email; }
get name(): string { return this.props.name; }
get passwordHash(): string { return this.props.passwordHash; }
get role(): Role { return this.props.role; }
get orgId(): string | undefined { return this.props.orgId; }
get createdAt(): Date { return this.props.createdAt; }
get updatedAt(): Date { return this.props.updatedAt; }
assignToOrg(orgId: string): void {
this.props.orgId = orgId;
this.props.updatedAt = new Date();
}
changeRole(role: Role): void {
this.props.role = role;
this.props.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,13 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class MemberInvited implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'auth.member.invited';
readonly occurredOn = new Date();
constructor(
readonly aggregateId: string,
readonly payload: Record<string, unknown>
) {}
}

View File

@@ -0,0 +1,13 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class OrgCreated implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'auth.org.created';
readonly occurredOn = new Date();
constructor(
readonly aggregateId: string,
readonly payload: Record<string, unknown>
) {}
}

View File

@@ -0,0 +1,13 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class UserCreated implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'auth.user.created';
readonly occurredOn = new Date();
constructor(
readonly aggregateId: string,
readonly payload: Record<string, unknown>
) {}
}

View File

@@ -0,0 +1,13 @@
import { randomUUID } from 'crypto';
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
export class UserLoggedIn implements DomainEvent {
readonly eventId = randomUUID();
readonly eventName = 'auth.user.logged_in';
readonly occurredOn = new Date();
constructor(
readonly aggregateId: string,
readonly payload: Record<string, unknown>
) {}
}

View File

@@ -0,0 +1,10 @@
import { ApiKey } from '../entities/ApiKey';
export interface IApiKeyRepository {
save(apiKey: ApiKey): Promise<void>;
findById(id: string): Promise<ApiKey | undefined>;
findByHash(keyHash: string): Promise<ApiKey | undefined>;
listByUser(userId: string): Promise<ApiKey[]>;
delete(id: string): Promise<void>;
updateLastUsed(id: string, lastUsedAt: Date): Promise<void>;
}

View File

@@ -0,0 +1,21 @@
import { Organization } from '../entities/Organization';
export interface OrgMember {
id: string;
orgId: string;
userId: string;
role: string;
joinedAt: Date;
}
export interface IOrganizationRepository {
save(org: Organization): Promise<void>;
findById(id: string): Promise<Organization | undefined>;
findBySlug(slug: string): Promise<Organization | undefined>;
findAll(): Promise<Organization[]>;
addMember(member: OrgMember): Promise<void>;
getMember(orgId: string, userId: string): Promise<OrgMember | undefined>;
listMembers(orgId: string): Promise<OrgMember[]>;
updateMemberRole(orgId: string, userId: string, role: string): Promise<void>;
removeMember(orgId: string, userId: string): Promise<void>;
}

View File

@@ -0,0 +1,14 @@
export interface AuthSession {
id: string;
userId: string;
token: string;
expiresAt: Date;
createdAt: Date;
}
export interface ISessionRepository {
save(session: AuthSession): Promise<void>;
findByToken(token: string): Promise<AuthSession | undefined>;
deleteByToken(token: string): Promise<void>;
deleteExpired(): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { User } from '../entities/User';
export interface IUserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | undefined>;
findByEmail(email: string): Promise<User | undefined>;
findAll(): Promise<User[]>;
count(): Promise<number>;
}

View File

@@ -0,0 +1,21 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
interface EmailProps {
value: string;
}
export class Email extends ValueObject<EmailProps> {
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
static create(value: string): Email {
const normalized = value.trim().toLowerCase();
if (!Email.EMAIL_REGEX.test(normalized)) {
throw new Error(`Invalid email address: ${value}`);
}
return new Email({ value: normalized });
}
get value(): string {
return this.props.value;
}
}

View File

@@ -0,0 +1,28 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'manage';
export type PermissionSubject =
| 'Session'
| 'Finding'
| 'Report'
| 'Integration'
| 'Organization'
| 'User'
| 'Settings'
| 'License'
| 'ApiKey'
| 'all';
interface PermissionProps {
action: PermissionAction;
subject: PermissionSubject;
}
export class Permission extends ValueObject<PermissionProps> {
static create(action: PermissionAction, subject: PermissionSubject): Permission {
return new Permission({ action, subject });
}
get action(): PermissionAction { return this.props.action; }
get subject(): PermissionSubject { return this.props.subject; }
}

View File

@@ -0,0 +1,37 @@
import { ValueObject } from '../../../../shared/domain/ValueObject';
export type RoleValue = 'owner' | 'admin' | 'member' | 'viewer';
interface RoleProps {
value: RoleValue;
}
export class Role extends ValueObject<RoleProps> {
static readonly OWNER: RoleValue = 'owner';
static readonly ADMIN: RoleValue = 'admin';
static readonly MEMBER: RoleValue = 'member';
static readonly VIEWER: RoleValue = 'viewer';
private static readonly VALID_ROLES: RoleValue[] = ['owner', 'admin', 'member', 'viewer'];
static create(value: string): Role {
if (!Role.VALID_ROLES.includes(value as RoleValue)) {
throw new Error(`Invalid role: ${value}. Must be one of: ${Role.VALID_ROLES.join(', ')}`);
}
return new Role({ value: value as RoleValue });
}
static owner(): Role { return new Role({ value: 'owner' }); }
static admin(): Role { return new Role({ value: 'admin' }); }
static member(): Role { return new Role({ value: 'member' }); }
static viewer(): Role { return new Role({ value: 'viewer' }); }
get value(): RoleValue {
return this.props.value;
}
isOwner(): boolean { return this.props.value === 'owner'; }
isAdmin(): boolean { return this.props.value === 'admin'; }
isMember(): boolean { return this.props.value === 'member'; }
isViewer(): boolean { return this.props.value === 'viewer'; }
}

28
src/modules/auth/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export { User } from './domain/entities/User';
export { Organization } from './domain/entities/Organization';
export { ApiKey } from './domain/entities/ApiKey';
export { Email } from './domain/value-objects/Email';
export { Role } from './domain/value-objects/Role';
export type { RoleValue } from './domain/value-objects/Role';
export { Permission } from './domain/value-objects/Permission';
export type { IUserRepository } from './domain/ports/IUserRepository';
export type { IOrganizationRepository, OrgMember } from './domain/ports/IOrganizationRepository';
export type { IApiKeyRepository } from './domain/ports/IApiKeyRepository';
export type { ISessionRepository, AuthSession } from './domain/ports/ISessionRepository';
export { RegisterCommand } from './application/commands/RegisterCommand';
export { LoginCommand } from './application/commands/LoginCommand';
export { CreateOrganizationCommand } from './application/commands/CreateOrganizationCommand';
export { InviteMemberCommand } from './application/commands/InviteMemberCommand';
export { CreateApiKeyCommand } from './application/commands/CreateApiKeyCommand';
export { GetUserQuery } from './application/queries/GetUserQuery';
export { ListOrgMembersQuery } from './application/queries/ListOrgMembersQuery';
export { createAuthMiddleware } from './application/middleware/AuthMiddleware';
export type { AuthenticatedUser } from './application/middleware/AuthMiddleware';
export { requirePermission } from './application/middleware/RBACMiddleware';
export { hashPassword, verifyPassword } from './infrastructure/auth/PasswordService';
export { defineAbilityFor } from './infrastructure/casl/AbilityFactory';
export { KyselyUserRepository } from './infrastructure/repositories/KyselyUserRepository';
export { KyselyOrganizationRepository } from './infrastructure/repositories/KyselyOrganizationRepository';
export { KyselyApiKeyRepository } from './infrastructure/repositories/KyselyApiKeyRepository';
export { KyselySessionRepository } from './infrastructure/repositories/KyselySessionRepository';
export { createAuthController } from './infrastructure/http/AuthController';

View File

@@ -0,0 +1,9 @@
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return argon2.verify(hash, password);
}

View File

@@ -0,0 +1,37 @@
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
import { RoleValue } from '../../domain/value-objects/Role';
import { PermissionSubject } from '../../domain/value-objects/Permission';
export type AppAbility = MongoAbility;
export function defineAbilityFor(role: RoleValue): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
switch (role) {
case 'owner':
can('manage', 'all');
break;
case 'admin':
can('manage', 'all');
cannot('delete', 'Organization' as PermissionSubject);
cannot('manage', 'License' as PermissionSubject);
can('read', 'License' as PermissionSubject);
break;
case 'member':
can('create', ['Session', 'Finding', 'Report'] as PermissionSubject[]);
can('read', 'all');
can('update', 'Finding' as PermissionSubject);
break;
case 'viewer':
can('read', 'all');
break;
default:
break;
}
return build();
}

View File

@@ -0,0 +1,210 @@
import { Router, Request, Response } from 'express';
import { RegisterCommand } from '../../application/commands/RegisterCommand';
import { LoginCommand } from '../../application/commands/LoginCommand';
import { CreateOrganizationCommand } from '../../application/commands/CreateOrganizationCommand';
import { InviteMemberCommand } from '../../application/commands/InviteMemberCommand';
import { CreateApiKeyCommand } from '../../application/commands/CreateApiKeyCommand';
import { GetUserQuery } from '../../application/queries/GetUserQuery';
import { ListOrgMembersQuery } from '../../application/queries/ListOrgMembersQuery';
import { ISessionRepository } from '../../domain/ports/ISessionRepository';
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { createAuthMiddleware } from '../../application/middleware/AuthMiddleware';
export function createAuthController(
registerCommand: RegisterCommand,
loginCommand: LoginCommand,
createOrgCommand: CreateOrganizationCommand,
inviteMemberCommand: InviteMemberCommand,
createApiKeyCommand: CreateApiKeyCommand,
getUserQuery: GetUserQuery,
listOrgMembersQuery: ListOrgMembersQuery,
sessionRepository: ISessionRepository,
apiKeyRepository: IApiKeyRepository,
userRepository: IUserRepository
): Router {
const router = Router();
const authMiddleware = createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository);
// POST /api/auth/register
router.post('/register', async (req: Request, res: Response) => {
const result = await registerCommand.execute({
email: req.body.email,
password: req.body.password,
name: req.body.name,
role: req.body.role,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
const result = await loginCommand.execute({
email: req.body.email,
password: req.body.password,
});
if (!result.ok) {
res.status(401).json({ error: result.error });
return;
}
const { sessionToken, expiresAt, ...userData } = result.value;
res.cookie('abe_session', sessionToken, {
httpOnly: true,
secure: process.env['NODE_ENV'] === 'production',
sameSite: 'lax',
expires: expiresAt,
});
res.json({ ...userData, sessionToken });
});
// POST /api/auth/logout
router.post('/logout', authMiddleware, async (req: Request, res: Response) => {
const token = req.cookies?.['abe_session'] ?? req.headers.authorization?.substring(7);
if (token) {
await sessionRepository.deleteByToken(token);
}
res.clearCookie('abe_session');
res.json({ success: true });
});
// GET /api/auth/me
router.get('/me', authMiddleware, async (req: Request, res: Response) => {
const result = await getUserQuery.execute({ userId: req.user!.id });
if (!result.ok) {
res.status(404).json({ error: result.error });
return;
}
res.json(result.value);
});
// GET /api/auth/setup-required
router.get('/setup-required', async (_req: Request, res: Response) => {
const count = await userRepository.count();
res.json({ required: count === 0 });
});
// POST /api/auth/setup — first-run setup
router.post('/setup', async (req: Request, res: Response) => {
const count = await userRepository.count();
if (count > 0) {
res.status(400).json({ error: 'Setup already completed' });
return;
}
const registerResult = await registerCommand.execute({
email: req.body.email,
password: req.body.password,
name: req.body.name,
role: 'owner',
});
if (!registerResult.ok) {
res.status(400).json({ error: registerResult.error });
return;
}
const createOrgResult = await createOrgCommand.execute({
name: req.body.orgName ?? 'My Organization',
ownerId: registerResult.value.userId,
});
if (!createOrgResult.ok) {
res.status(400).json({ error: createOrgResult.error });
return;
}
res.status(201).json({
user: registerResult.value,
organization: createOrgResult.value,
});
});
// POST /api/auth/organizations — create org
router.post('/organizations', authMiddleware, async (req: Request, res: Response) => {
const result = await createOrgCommand.execute({
name: req.body.name,
ownerId: req.user!.id,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// POST /api/auth/organizations/:orgId/members — invite member
router.post('/organizations/:orgId/members', authMiddleware, async (req: Request, res: Response) => {
const result = await inviteMemberCommand.execute({
orgId: String(req.params['orgId']),
inviterUserId: req.user!.id,
email: req.body.email,
role: req.body.role ?? 'member',
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// GET /api/auth/organizations/:orgId/members
router.get('/organizations/:orgId/members', authMiddleware, async (req: Request, res: Response) => {
const result = await listOrgMembersQuery.execute({ orgId: String(req.params['orgId']) });
if (!result.ok) {
res.status(404).json({ error: result.error });
return;
}
res.json(result.value);
});
// POST /api/auth/api-keys — create API key
router.post('/api-keys', authMiddleware, async (req: Request, res: Response) => {
const result = await createApiKeyCommand.execute({
userId: req.user!.id,
orgId: req.user!.orgId ?? 'default',
name: req.body.name,
permissions: req.body.permissions,
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
});
if (!result.ok) {
res.status(400).json({ error: result.error });
return;
}
res.status(201).json(result.value);
});
// GET /api/auth/api-keys — list API keys
router.get('/api-keys', authMiddleware, async (req: Request, res: Response) => {
const keys = await apiKeyRepository.listByUser(req.user!.id);
res.json(
keys.map((k) => ({
id: k.id.toString(),
name: k.name,
keyPrefix: k.keyPrefix,
permissions: k.permissions,
expiresAt: k.expiresAt,
lastUsedAt: k.lastUsedAt,
createdAt: k.createdAt,
}))
);
});
// DELETE /api/auth/api-keys/:id — revoke API key
router.delete('/api-keys/:id', authMiddleware, async (req: Request, res: Response) => {
const keyId = String(req.params['id']);
const key = await apiKeyRepository.findById(keyId);
if (!key || key.userId !== req.user!.id) {
res.status(404).json({ error: 'API key not found' });
return;
}
await apiKeyRepository.delete(keyId);
res.json({ success: true });
});
return router;
}

View File

@@ -0,0 +1,94 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
import { ApiKey } from '../../domain/entities/ApiKey';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export class KyselyApiKeyRepository implements IApiKeyRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(apiKey: ApiKey): Promise<void> {
await this.db
.insertInto('api_keys')
.values({
id: apiKey.id.toString(),
user_id: apiKey.userId,
org_id: apiKey.orgId,
name: apiKey.name,
key_hash: apiKey.keyHash,
key_prefix: apiKey.keyPrefix,
permissions: JSON.stringify(apiKey.permissions),
expires_at: apiKey.expiresAt ? apiKey.expiresAt.getTime() : null,
last_used_at: apiKey.lastUsedAt ? apiKey.lastUsedAt.getTime() : null,
created_at: apiKey.createdAt.getTime(),
})
.execute();
}
async findById(id: string): Promise<ApiKey | undefined> {
const row = await this.db
.selectFrom('api_keys')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findByHash(keyHash: string): Promise<ApiKey | undefined> {
const row = await this.db
.selectFrom('api_keys')
.selectAll()
.where('key_hash', '=', keyHash)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async listByUser(userId: string): Promise<ApiKey[]> {
const rows = await this.db
.selectFrom('api_keys')
.selectAll()
.where('user_id', '=', userId)
.execute();
return rows.map((r) => this.toDomain(r));
}
async delete(id: string): Promise<void> {
await this.db.deleteFrom('api_keys').where('id', '=', id).execute();
}
async updateLastUsed(id: string, lastUsedAt: Date): Promise<void> {
await this.db
.updateTable('api_keys')
.set({ last_used_at: lastUsedAt.getTime() })
.where('id', '=', id)
.execute();
}
private toDomain(row: {
id: string;
user_id: string;
org_id: string;
name: string;
key_hash: string;
key_prefix: string;
permissions: string;
expires_at: number | null;
last_used_at: number | null;
created_at: number;
}): ApiKey {
return ApiKey.reconstitute(
{
userId: row.user_id,
orgId: row.org_id,
name: row.name,
keyHash: row.key_hash,
keyPrefix: row.key_prefix,
permissions: JSON.parse(row.permissions) as string[],
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : undefined,
createdAt: new Date(row.created_at),
},
UniqueId.from(row.id)
);
}
}

View File

@@ -0,0 +1,111 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { IOrganizationRepository, OrgMember } from '../../domain/ports/IOrganizationRepository';
import { Organization } from '../../domain/entities/Organization';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export class KyselyOrganizationRepository implements IOrganizationRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(org: Organization): Promise<void> {
await this.db
.insertInto('organizations')
.values({
id: org.id.toString(),
name: org.name,
slug: org.slug,
created_at: org.createdAt.getTime(),
})
.onConflict((oc) =>
oc.column('id').doUpdateSet({ name: org.name })
)
.execute();
}
async findById(id: string): Promise<Organization | undefined> {
const row = await this.db
.selectFrom('organizations')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findBySlug(slug: string): Promise<Organization | undefined> {
const row = await this.db
.selectFrom('organizations')
.selectAll()
.where('slug', '=', slug)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll(): Promise<Organization[]> {
const rows = await this.db.selectFrom('organizations').selectAll().execute();
return rows.map((r) => this.toDomain(r));
}
async addMember(member: OrgMember): Promise<void> {
await this.db
.insertInto('org_members')
.values({
id: member.id,
org_id: member.orgId,
user_id: member.userId,
role: member.role,
joined_at: member.joinedAt.getTime(),
})
.execute();
}
async getMember(orgId: string, userId: string): Promise<OrgMember | undefined> {
const row = await this.db
.selectFrom('org_members')
.selectAll()
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.executeTakeFirst();
return row
? { id: row.id, orgId: row.org_id, userId: row.user_id, role: row.role, joinedAt: new Date(row.joined_at) }
: undefined;
}
async listMembers(orgId: string): Promise<OrgMember[]> {
const rows = await this.db
.selectFrom('org_members')
.selectAll()
.where('org_id', '=', orgId)
.execute();
return rows.map((r) => ({
id: r.id,
orgId: r.org_id,
userId: r.user_id,
role: r.role,
joinedAt: new Date(r.joined_at),
}));
}
async updateMemberRole(orgId: string, userId: string, role: string): Promise<void> {
await this.db
.updateTable('org_members')
.set({ role })
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.execute();
}
async removeMember(orgId: string, userId: string): Promise<void> {
await this.db
.deleteFrom('org_members')
.where('org_id', '=', orgId)
.where('user_id', '=', userId)
.execute();
}
private toDomain(row: { id: string; name: string; slug: string; created_at: number }): Organization {
return Organization.reconstitute(
{ name: row.name, slug: row.slug, createdAt: new Date(row.created_at) },
UniqueId.from(row.id)
);
}
}

View File

@@ -0,0 +1,47 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { ISessionRepository, AuthSession } from '../../domain/ports/ISessionRepository';
export class KyselySessionRepository implements ISessionRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(session: AuthSession): Promise<void> {
await this.db
.insertInto('auth_sessions')
.values({
id: session.id,
user_id: session.userId,
token: session.token,
expires_at: session.expiresAt.getTime(),
created_at: session.createdAt.getTime(),
})
.execute();
}
async findByToken(token: string): Promise<AuthSession | undefined> {
const row = await this.db
.selectFrom('auth_sessions')
.selectAll()
.where('token', '=', token)
.executeTakeFirst();
if (!row) return undefined;
return {
id: row.id,
userId: row.user_id,
token: row.token,
expiresAt: new Date(row.expires_at),
createdAt: new Date(row.created_at),
};
}
async deleteByToken(token: string): Promise<void> {
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
}
async deleteExpired(): Promise<void> {
await this.db
.deleteFrom('auth_sessions')
.where('expires_at', '<', Date.now())
.execute();
}
}

View File

@@ -0,0 +1,92 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { IUserRepository } from '../../domain/ports/IUserRepository';
import { User } from '../../domain/entities/User';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { Email } from '../../domain/value-objects/Email';
import { Role } from '../../domain/value-objects/Role';
export class KyselyUserRepository implements IUserRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(user: User): Promise<void> {
const row = {
id: user.id.toString(),
email: user.email.value,
name: user.name,
password_hash: user.passwordHash,
role: user.role.value,
org_id: user.orgId ?? null,
created_at: user.createdAt.getTime(),
updated_at: user.updatedAt.getTime(),
};
await this.db
.insertInto('users')
.values(row)
.onConflict((oc) =>
oc.column('id').doUpdateSet({
name: row.name,
role: row.role,
org_id: row.org_id,
updated_at: row.updated_at,
})
)
.execute();
}
async findById(id: string): Promise<User | undefined> {
const row = await this.db
.selectFrom('users')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findByEmail(email: string): Promise<User | undefined> {
const row = await this.db
.selectFrom('users')
.selectAll()
.where('email', '=', email.toLowerCase())
.executeTakeFirst();
return row ? this.toDomain(row) : undefined;
}
async findAll(): Promise<User[]> {
const rows = await this.db.selectFrom('users').selectAll().execute();
return rows.map((r) => this.toDomain(r));
}
async count(): Promise<number> {
const result = await this.db
.selectFrom('users')
.select((eb) => eb.fn.count('id').as('count'))
.executeTakeFirstOrThrow();
return Number(result.count);
}
private toDomain(row: {
id: string;
email: string;
name: string;
password_hash: string;
role: string;
org_id: string | null;
created_at: number;
updated_at: number;
}): User {
return User.reconstitute(
{
email: Email.create(row.email),
name: row.name,
passwordHash: row.password_hash,
role: Role.create(row.role),
orgId: row.org_id ?? undefined,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
},
UniqueId.from(row.id)
);
}
}

View File

@@ -154,6 +154,53 @@ export interface JobTable {
updated_at: string;
}
export interface UserTable {
id: string;
email: string;
name: string;
password_hash: string;
role: string;
org_id: string | null;
created_at: number;
updated_at: number;
}
export interface OrganizationTable {
id: string;
name: string;
slug: string;
created_at: number;
}
export interface OrgMemberTable {
id: string;
org_id: string;
user_id: string;
role: string;
joined_at: number;
}
export interface ApiKeyTable {
id: string;
user_id: string;
org_id: string;
name: string;
key_hash: string;
key_prefix: string;
permissions: string;
expires_at: number | null;
last_used_at: number | null;
created_at: number;
}
export interface AuthSessionTable {
id: string;
user_id: string;
token: string;
expires_at: number;
created_at: number;
}
export interface Database {
sessions: SessionTable;
states: StateTable;
@@ -166,6 +213,11 @@ export interface Database {
performance_metrics: PerformanceMetricTable;
findings: FindingTable;
jobs: JobTable;
users: UserTable;
organizations: OrganizationTable;
org_members: OrgMemberTable;
api_keys: ApiKeyTable;
auth_sessions: AuthSessionTable;
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {