diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 3b2bd44..2623ea6 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -e746dc049766347ca4d135ec35e86cbef2f90261 +39a5e41f755b1cb2f84eee1add7bc9550be40202 diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index cfdb8c7..b7f005e 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -143,42 +143,42 @@ Spec: `.ralph/specs/phase-07-api-server.md` --- -## Phase 8: Job Queue System [PENDIENTE] +## Phase 8: Job Queue System [COMPLETO] Spec: `.ralph/specs/phase-08-job-queue.md` -- [ ] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive -- [ ] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker -- [ ] 8.3: Migración Kysely: tabla jobs -- [ ] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job -- [ ] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background -- [ ] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based -- [ ] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry -- [ ] 8.8: Verificar build + commit: `fase(8): sqlite job queue system` +- [x] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive +- [x] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker +- [x] 8.3: Migración Kysely: tabla jobs +- [x] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job +- [x] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background +- [x] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based +- [x] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry +- [x] 8.8: Verificar build + commit: `fase(8): sqlite job queue system` --- -## Phase 9: Auth Module [PENDIENTE] +## Phase 9: Auth Module [COMPLETO] Spec: `.ralph/specs/phase-09-auth-module.md` -- [ ] 9.1: Instalar: `npm i better-auth @casl/ability argon2` -- [ ] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `Team.ts` (Entity), `ApiKey.ts` (Entity) -- [ ] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts` -- [ ] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts` -- [ ] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts` -- [ ] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts` -- [ ] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts` -- [ ] 9.8: Crear `infrastructure/better-auth/authConfig.ts` — setup Better Auth con SQLite adapter, email+password, organization plugin con roles -- [ ] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role (owner: manage all, admin: manage all except delete org, member: create/read sessions+findings, viewer: read all) -- [ ] 9.10: Crear `application/middleware/AuthMiddleware.ts` — intenta session cookie → JWT → API key → 401 -- [ ] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL por ruta -- [ ] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` -- [ ] 9.13: Crear `infrastructure/http/AuthController.ts` — POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, GET /api/auth/setup-required -- [ ] 9.14: Migración Kysely: tablas users, organizations, teams, org_members, api_keys, auth_sessions -- [ ] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true } -- [ ] 9.16: POST /api/auth/setup — crea primer user como owner + organización default -- [ ] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /health/* y /api/auth/* -- [ ] 9.18: Tests: register, login, RBAC permissions (admin can create session, viewer cannot) -- [ ] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl` +- [x] 9.1: Instalar: `npm i @casl/ability argon2 cookie-parser` (custom auth sin better-auth, per spec nota) +- [x] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `ApiKey.ts` (Entity) +- [x] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts` +- [x] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts` +- [x] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`, `IApiKeyRepository.ts`, `ISessionRepository.ts` +- [x] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts` +- [x] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts` +- [x] 9.8: Crear `infrastructure/auth/PasswordService.ts` — argon2 hash/verify +- [x] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role +- [x] 9.10: Crear `application/middleware/AuthMiddleware.ts` — cookie → Bearer → API key → 401 +- [x] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL +- [x] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` + Org + ApiKey + Session repos +- [x] 9.13: Crear `infrastructure/http/AuthController.ts` — register, login, logout, me, setup-required, setup, orgs, api-keys +- [x] 9.14: Migración Kysely: tablas users, organizations, org_members, api_keys, auth_sessions +- [x] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true } +- [x] 9.16: POST /api/auth/setup — crea primer user como owner + organización default +- [x] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /api/auth/* +- [x] 9.18: Tests: Email, Role, User, Organization, RegisterCommand, LoginCommand, CASL (23 tests) +- [x] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index 6a2e39b..1e0df91 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1 +1 @@ -{"status": "completed", "timestamp": "2026-03-05 09:23:22"} +{"status": "failed", "timestamp": "2026-03-05 09:44:36"} diff --git a/dist/api/router.js b/dist/api/router.js index 1c71e42..ac2faf1 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -8,8 +8,16 @@ const express_1 = require("express"); const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController"); const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController"); const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController"); +const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController"); +const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware"); function createRouter(deps) { const router = (0, express_1.Router)(); + const { authDeps } = deps; + // Auth routes — public (no auth middleware) + router.use('/auth', (0, AuthController_1.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 = (0, AuthMiddleware_1.createAuthMiddleware)(authDeps.userRepository, authDeps.sessionRepository, authDeps.apiKeyRepository); + router.use(authMiddleware); router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps)); router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps)); router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps)); diff --git a/dist/api/server.js b/dist/api/server.js index d0ccf1b..0329343 100644 --- a/dist/api/server.js +++ b/dist/api/server.js @@ -12,6 +12,7 @@ const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const helmet_1 = __importDefault(require("helmet")); const express_rate_limit_1 = __importDefault(require("express-rate-limit")); +const cookie_parser_1 = __importDefault(require("cookie-parser")); const requestId_1 = require("./middleware/requestId"); const notFound_1 = require("./middleware/notFound"); const errorHandler_1 = require("./middleware/errorHandler"); @@ -39,8 +40,9 @@ function createServer(deps) { standardHeaders: true, legacyHeaders: false, })); - // 5. Body parsing + // 5. Body parsing + cookies app.use(express_1.default.json({ limit: '10mb' })); + app.use((0, cookie_parser_1.default)()); // 6. Health endpoints — no auth required app.get('/health/live', (_req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); diff --git a/dist/db/migrations/004_auth_tables.js b/dist/db/migrations/004_auth_tables.js new file mode 100644 index 0000000..3949217 --- /dev/null +++ b/dist/db/migrations/004_auth_tables.js @@ -0,0 +1,81 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.up = up; +exports.down = down; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function up(db) { + 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 +async function down(db) { + 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(); +} diff --git a/dist/main.js b/dist/main.js index 1575458..7b898df 100644 --- a/dist/main.js +++ b/dist/main.js @@ -36,6 +36,19 @@ const FuzzingEngineAdapter_1 = require("./modules/fuzzing/infrastructure/adapter const RunFuzzCommand_1 = require("./modules/fuzzing/application/commands/RunFuzzCommand"); const OnActionExecuted_1 = require("./modules/fuzzing/application/event-handlers/OnActionExecuted"); const InMemoryFuzzSessionRepository_1 = require("./modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository"); +// Auth module +const KyselyUserRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyUserRepository"); +const KyselyOrganizationRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyOrganizationRepository"); +const KyselyApiKeyRepository_1 = require("./modules/auth/infrastructure/repositories/KyselyApiKeyRepository"); +const KyselySessionRepository_1 = require("./modules/auth/infrastructure/repositories/KyselySessionRepository"); +const RegisterCommand_1 = require("./modules/auth/application/commands/RegisterCommand"); +const LoginCommand_1 = require("./modules/auth/application/commands/LoginCommand"); +const CreateOrganizationCommand_1 = require("./modules/auth/application/commands/CreateOrganizationCommand"); +const InviteMemberCommand_1 = require("./modules/auth/application/commands/InviteMemberCommand"); +const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/CreateApiKeyCommand"); +const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery"); +const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery"); +const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService"); // Job queue const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue"); const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker"); @@ -83,7 +96,19 @@ async function bootstrap() { eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected); const onActionExecuted = new OnActionExecuted_1.OnActionExecuted(runFuzz); eventBus.subscribe('crawling.action_executed', onActionExecuted); - // 10. HTTP server + // 10. Auth module + const userRepo = new KyselyUserRepository_1.KyselyUserRepository(db); + const orgRepo = new KyselyOrganizationRepository_1.KyselyOrganizationRepository(db); + const apiKeyRepo = new KyselyApiKeyRepository_1.KyselyApiKeyRepository(db); + const authSessionRepo = new KyselySessionRepository_1.KyselySessionRepository(db); + const registerCommand = new RegisterCommand_1.RegisterCommand(userRepo, eventBus, PasswordService_1.hashPassword); + const loginCommand = new LoginCommand_1.LoginCommand(userRepo, authSessionRepo, eventBus, PasswordService_1.verifyPassword); + const createOrgCommand = new CreateOrganizationCommand_1.CreateOrganizationCommand(orgRepo, userRepo, eventBus); + const inviteMemberCommand = new InviteMemberCommand_1.InviteMemberCommand(orgRepo, userRepo, eventBus); + const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo); + const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo); + const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo); + // 11. HTTP server const app = (0, server_1.createServer)({ config, logger, @@ -91,6 +116,18 @@ async function bootstrap() { 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_1.default.createServer(app); // 11. Job queue diff --git a/dist/modules/auth/application/commands/CreateApiKeyCommand.js b/dist/modules/auth/application/commands/CreateApiKeyCommand.js new file mode 100644 index 0000000..dbd3f80 --- /dev/null +++ b/dist/modules/auth/application/commands/CreateApiKeyCommand.js @@ -0,0 +1,41 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CreateApiKeyCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const ApiKey_1 = require("../../domain/entities/ApiKey"); +const crypto_1 = require("crypto"); +class CreateApiKeyCommand { + constructor(apiKeyRepository, userRepository) { + this.apiKeyRepository = apiKeyRepository; + this.userRepository = userRepository; + } + async execute(request) { + const user = await this.userRepository.findById(request.userId); + if (!user) { + return (0, Result_1.Err)('User not found'); + } + if (!request.name.trim()) { + return (0, Result_1.Err)('API key name is required'); + } + const rawKey = `abe_${(0, crypto_1.randomBytes)(32).toString('hex')}`; + const keyHash = (0, crypto_1.createHash)('sha256').update(rawKey).digest('hex'); + const keyPrefix = rawKey.substring(0, 12); + const apiKey = ApiKey_1.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 (0, Result_1.Ok)({ + id: apiKey.id.toString(), + key: rawKey, + keyPrefix, + name: apiKey.name, + }); + } +} +exports.CreateApiKeyCommand = CreateApiKeyCommand; diff --git a/dist/modules/auth/application/commands/CreateOrganizationCommand.js b/dist/modules/auth/application/commands/CreateOrganizationCommand.js new file mode 100644 index 0000000..6153044 --- /dev/null +++ b/dist/modules/auth/application/commands/CreateOrganizationCommand.js @@ -0,0 +1,48 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CreateOrganizationCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Organization_1 = require("../../domain/entities/Organization"); +const crypto_1 = require("crypto"); +class CreateOrganizationCommand { + constructor(orgRepository, userRepository, eventBus) { + this.orgRepository = orgRepository; + this.userRepository = userRepository; + this.eventBus = eventBus; + } + async execute(request) { + const user = await this.userRepository.findById(request.ownerId); + if (!user) { + return (0, Result_1.Err)('User not found'); + } + const slug = Organization_1.Organization.slugify(request.name); + if (!slug) { + return (0, Result_1.Err)('Invalid organization name'); + } + const existing = await this.orgRepository.findBySlug(slug); + if (existing) { + return (0, Result_1.Err)('Organization name already taken'); + } + const org = Organization_1.Organization.create({ name: request.name, slug }); + await this.orgRepository.save(org); + await this.orgRepository.addMember({ + id: (0, crypto_1.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 (0, Result_1.Ok)({ + orgId: org.id.toString(), + name: org.name, + slug: org.slug, + }); + } +} +exports.CreateOrganizationCommand = CreateOrganizationCommand; diff --git a/dist/modules/auth/application/commands/InviteMemberCommand.js b/dist/modules/auth/application/commands/InviteMemberCommand.js new file mode 100644 index 0000000..4f9178e --- /dev/null +++ b/dist/modules/auth/application/commands/InviteMemberCommand.js @@ -0,0 +1,63 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.InviteMemberCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Email_1 = require("../../domain/value-objects/Email"); +const Role_1 = require("../../domain/value-objects/Role"); +const MemberInvited_1 = require("../../domain/events/MemberInvited"); +const crypto_1 = require("crypto"); +class InviteMemberCommand { + constructor(orgRepository, userRepository, eventBus) { + this.orgRepository = orgRepository; + this.userRepository = userRepository; + this.eventBus = eventBus; + } + async execute(request) { + const org = await this.orgRepository.findById(request.orgId); + if (!org) { + return (0, Result_1.Err)('Organization not found'); + } + let email; + try { + email = Email_1.Email.create(request.email); + } + catch { + return (0, Result_1.Err)('Invalid email address'); + } + let role; + try { + role = Role_1.Role.create(request.role); + } + catch { + return (0, Result_1.Err)('Invalid role'); + } + const user = await this.userRepository.findByEmail(email.value); + if (!user) { + return (0, Result_1.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 (0, Result_1.Err)('User is already a member of this organization'); + } + const memberId = (0, crypto_1.randomUUID)(); + await this.orgRepository.addMember({ + id: memberId, + orgId: request.orgId, + userId: user.id.toString(), + role: role.value, + joinedAt: new Date(), + }); + const event = new MemberInvited_1.MemberInvited(request.orgId, { + email: email.value, + role: role.value, + inviterUserId: request.inviterUserId, + }); + await this.eventBus.publish(event); + return (0, Result_1.Ok)({ + memberId, + email: email.value, + role: role.value, + }); + } +} +exports.InviteMemberCommand = InviteMemberCommand; diff --git a/dist/modules/auth/application/commands/LoginCommand.js b/dist/modules/auth/application/commands/LoginCommand.js new file mode 100644 index 0000000..32e6bde --- /dev/null +++ b/dist/modules/auth/application/commands/LoginCommand.js @@ -0,0 +1,56 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LoginCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Email_1 = require("../../domain/value-objects/Email"); +const UserLoggedIn_1 = require("../../domain/events/UserLoggedIn"); +const crypto_1 = require("crypto"); +class LoginCommand { + constructor(userRepository, sessionRepository, eventBus, verifyPassword, sessionMaxAgeSeconds = 7 * 24 * 60 * 60) { + this.userRepository = userRepository; + this.sessionRepository = sessionRepository; + this.eventBus = eventBus; + this.verifyPassword = verifyPassword; + this.sessionMaxAgeSeconds = sessionMaxAgeSeconds; + } + async execute(request) { + let email; + try { + email = Email_1.Email.create(request.email); + } + catch { + return (0, Result_1.Err)('Invalid credentials'); + } + const user = await this.userRepository.findByEmail(email.value); + if (!user) { + return (0, Result_1.Err)('Invalid credentials'); + } + const valid = await this.verifyPassword(request.password, user.passwordHash); + if (!valid) { + return (0, Result_1.Err)('Invalid credentials'); + } + const token = (0, crypto_1.randomUUID)(); + const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000); + const session = { + id: (0, crypto_1.randomUUID)(), + userId: user.id.toString(), + token, + expiresAt, + createdAt: new Date(), + }; + await this.sessionRepository.save(session); + const event = new UserLoggedIn_1.UserLoggedIn(user.id.toString(), { + email: user.email.value, + sessionId: session.id, + }); + await this.eventBus.publish(event); + return (0, Result_1.Ok)({ + userId: user.id.toString(), + sessionToken: token, + expiresAt, + role: user.role.value, + name: user.name, + }); + } +} +exports.LoginCommand = LoginCommand; diff --git a/dist/modules/auth/application/commands/RegisterCommand.js b/dist/modules/auth/application/commands/RegisterCommand.js new file mode 100644 index 0000000..c77c100 --- /dev/null +++ b/dist/modules/auth/application/commands/RegisterCommand.js @@ -0,0 +1,51 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RegisterCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const User_1 = require("../../domain/entities/User"); +const Email_1 = require("../../domain/value-objects/Email"); +const Role_1 = require("../../domain/value-objects/Role"); +class RegisterCommand { + constructor(userRepository, eventBus, hashPassword) { + this.userRepository = userRepository; + this.eventBus = eventBus; + this.hashPassword = hashPassword; + } + async execute(request) { + let email; + try { + email = Email_1.Email.create(request.email); + } + catch { + return (0, Result_1.Err)('Invalid email address'); + } + const existing = await this.userRepository.findByEmail(email.value); + if (existing) { + return (0, Result_1.Err)('Email already registered'); + } + if (request.password.length < 8) { + return (0, Result_1.Err)('Password must be at least 8 characters'); + } + let role; + try { + role = request.role ? Role_1.Role.create(request.role) : Role_1.Role.member(); + } + catch { + return (0, Result_1.Err)('Invalid role'); + } + const passwordHash = await this.hashPassword(request.password); + const user = User_1.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 (0, Result_1.Ok)({ + userId: user.id.toString(), + email: user.email.value, + name: user.name, + role: user.role.value, + }); + } +} +exports.RegisterCommand = RegisterCommand; diff --git a/dist/modules/auth/application/middleware/AuthMiddleware.js b/dist/modules/auth/application/middleware/AuthMiddleware.js new file mode 100644 index 0000000..f82f23c --- /dev/null +++ b/dist/modules/auth/application/middleware/AuthMiddleware.js @@ -0,0 +1,71 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAuthMiddleware = createAuthMiddleware; +const crypto_1 = require("crypto"); +function createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository) { + return async function authMiddleware(req, res, next) { + 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 = (0, crypto_1.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' }); + } + }; +} diff --git a/dist/modules/auth/application/middleware/RBACMiddleware.js b/dist/modules/auth/application/middleware/RBACMiddleware.js new file mode 100644 index 0000000..366cad6 --- /dev/null +++ b/dist/modules/auth/application/middleware/RBACMiddleware.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.requirePermission = requirePermission; +const AbilityFactory_1 = require("../../infrastructure/casl/AbilityFactory"); +function requirePermission(action, subject) { + return function rbacMiddleware(req, res, next) { + if (!req.user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + const ability = (0, AbilityFactory_1.defineAbilityFor)(req.user.role); + if (!ability.can(action, subject)) { + res.status(403).json({ + error: 'Forbidden', + message: `You do not have permission to ${action} ${subject}`, + }); + return; + } + next(); + }; +} diff --git a/dist/modules/auth/application/queries/GetUserQuery.js b/dist/modules/auth/application/queries/GetUserQuery.js new file mode 100644 index 0000000..ca24c3e --- /dev/null +++ b/dist/modules/auth/application/queries/GetUserQuery.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GetUserQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class GetUserQuery { + constructor(userRepository) { + this.userRepository = userRepository; + } + async execute(request) { + const user = await this.userRepository.findById(request.userId); + if (!user) { + return (0, Result_1.Err)('User not found'); + } + return (0, Result_1.Ok)({ + id: user.id.toString(), + email: user.email.value, + name: user.name, + role: user.role.value, + orgId: user.orgId, + createdAt: user.createdAt, + }); + } +} +exports.GetUserQuery = GetUserQuery; diff --git a/dist/modules/auth/application/queries/ListOrgMembersQuery.js b/dist/modules/auth/application/queries/ListOrgMembersQuery.js new file mode 100644 index 0000000..50aff19 --- /dev/null +++ b/dist/modules/auth/application/queries/ListOrgMembersQuery.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ListOrgMembersQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class ListOrgMembersQuery { + constructor(orgRepository, userRepository) { + this.orgRepository = orgRepository; + this.userRepository = userRepository; + } + async execute(request) { + const org = await this.orgRepository.findById(request.orgId); + if (!org) { + return (0, Result_1.Err)('Organization not found'); + } + const members = await this.orgRepository.listMembers(request.orgId); + const dtos = []; + 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 (0, Result_1.Ok)({ members: dtos, total: dtos.length }); + } +} +exports.ListOrgMembersQuery = ListOrgMembersQuery; diff --git a/dist/modules/auth/domain/entities/ApiKey.js b/dist/modules/auth/domain/entities/ApiKey.js new file mode 100644 index 0000000..c91055a --- /dev/null +++ b/dist/modules/auth/domain/entities/ApiKey.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ApiKey = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +class ApiKey extends AggregateRoot_1.AggregateRoot { + static create(props, id) { + const keyId = id ?? UniqueId_1.UniqueId.create(); + return new ApiKey({ + ...props, + createdAt: new Date(), + }, keyId); + } + static reconstitute(props, id) { + return new ApiKey(props, id); + } + get userId() { return this.props.userId; } + get orgId() { return this.props.orgId; } + get name() { return this.props.name; } + get keyHash() { return this.props.keyHash; } + get keyPrefix() { return this.props.keyPrefix; } + get permissions() { return this.props.permissions; } + get expiresAt() { return this.props.expiresAt; } + get lastUsedAt() { return this.props.lastUsedAt; } + get createdAt() { return this.props.createdAt; } + isExpired() { + if (!this.props.expiresAt) + return false; + return new Date() > this.props.expiresAt; + } + markUsed() { + this.props.lastUsedAt = new Date(); + } +} +exports.ApiKey = ApiKey; diff --git a/dist/modules/auth/domain/entities/Organization.js b/dist/modules/auth/domain/entities/Organization.js new file mode 100644 index 0000000..e5401b6 --- /dev/null +++ b/dist/modules/auth/domain/entities/Organization.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Organization = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const OrgCreated_1 = require("../events/OrgCreated"); +class Organization extends AggregateRoot_1.AggregateRoot { + static create(props, id) { + const orgId = id ?? UniqueId_1.UniqueId.create(); + const org = new Organization({ + ...props, + createdAt: new Date(), + }, orgId); + org.addDomainEvent(new OrgCreated_1.OrgCreated(orgId.toString(), { + name: props.name, + slug: props.slug, + })); + return org; + } + static reconstitute(props, id) { + return new Organization(props, id); + } + static slugify(name) { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + } + get name() { return this.props.name; } + get slug() { return this.props.slug; } + get createdAt() { return this.props.createdAt; } +} +exports.Organization = Organization; diff --git a/dist/modules/auth/domain/entities/User.js b/dist/modules/auth/domain/entities/User.js new file mode 100644 index 0000000..a00e6e4 --- /dev/null +++ b/dist/modules/auth/domain/entities/User.js @@ -0,0 +1,42 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.User = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const UserCreated_1 = require("../events/UserCreated"); +class User extends AggregateRoot_1.AggregateRoot { + static create(props, id) { + const userId = id ?? UniqueId_1.UniqueId.create(); + const now = new Date(); + const user = new User({ + ...props, + createdAt: now, + updatedAt: now, + }, userId); + user.addDomainEvent(new UserCreated_1.UserCreated(userId.toString(), { + email: props.email.value, + name: props.name, + role: props.role.value, + })); + return user; + } + static reconstitute(props, id) { + return new User(props, id); + } + get email() { return this.props.email; } + get name() { return this.props.name; } + get passwordHash() { return this.props.passwordHash; } + get role() { return this.props.role; } + get orgId() { return this.props.orgId; } + get createdAt() { return this.props.createdAt; } + get updatedAt() { return this.props.updatedAt; } + assignToOrg(orgId) { + this.props.orgId = orgId; + this.props.updatedAt = new Date(); + } + changeRole(role) { + this.props.role = role; + this.props.updatedAt = new Date(); + } +} +exports.User = User; diff --git a/dist/modules/auth/domain/events/MemberInvited.js b/dist/modules/auth/domain/events/MemberInvited.js new file mode 100644 index 0000000..bf8383b --- /dev/null +++ b/dist/modules/auth/domain/events/MemberInvited.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MemberInvited = void 0; +const crypto_1 = require("crypto"); +class MemberInvited { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'auth.member.invited'; + this.occurredOn = new Date(); + } +} +exports.MemberInvited = MemberInvited; diff --git a/dist/modules/auth/domain/events/OrgCreated.js b/dist/modules/auth/domain/events/OrgCreated.js new file mode 100644 index 0000000..9d8612d --- /dev/null +++ b/dist/modules/auth/domain/events/OrgCreated.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OrgCreated = void 0; +const crypto_1 = require("crypto"); +class OrgCreated { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'auth.org.created'; + this.occurredOn = new Date(); + } +} +exports.OrgCreated = OrgCreated; diff --git a/dist/modules/auth/domain/events/UserCreated.js b/dist/modules/auth/domain/events/UserCreated.js new file mode 100644 index 0000000..2bed7fc --- /dev/null +++ b/dist/modules/auth/domain/events/UserCreated.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UserCreated = void 0; +const crypto_1 = require("crypto"); +class UserCreated { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'auth.user.created'; + this.occurredOn = new Date(); + } +} +exports.UserCreated = UserCreated; diff --git a/dist/modules/auth/domain/events/UserLoggedIn.js b/dist/modules/auth/domain/events/UserLoggedIn.js new file mode 100644 index 0000000..6d81d28 --- /dev/null +++ b/dist/modules/auth/domain/events/UserLoggedIn.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UserLoggedIn = void 0; +const crypto_1 = require("crypto"); +class UserLoggedIn { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'auth.user.logged_in'; + this.occurredOn = new Date(); + } +} +exports.UserLoggedIn = UserLoggedIn; diff --git a/dist/modules/auth/domain/ports/IApiKeyRepository.js b/dist/modules/auth/domain/ports/IApiKeyRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/auth/domain/ports/IApiKeyRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/auth/domain/ports/IOrganizationRepository.js b/dist/modules/auth/domain/ports/IOrganizationRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/auth/domain/ports/IOrganizationRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/auth/domain/ports/ISessionRepository.js b/dist/modules/auth/domain/ports/ISessionRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/auth/domain/ports/ISessionRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/auth/domain/ports/IUserRepository.js b/dist/modules/auth/domain/ports/IUserRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/auth/domain/ports/IUserRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/auth/domain/value-objects/Email.js b/dist/modules/auth/domain/value-objects/Email.js new file mode 100644 index 0000000..711fb4d --- /dev/null +++ b/dist/modules/auth/domain/value-objects/Email.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Email = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Email extends ValueObject_1.ValueObject { + static create(value) { + 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() { + return this.props.value; + } +} +exports.Email = Email; +Email.EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/dist/modules/auth/domain/value-objects/Permission.js b/dist/modules/auth/domain/value-objects/Permission.js new file mode 100644 index 0000000..4222d5a --- /dev/null +++ b/dist/modules/auth/domain/value-objects/Permission.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Permission = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Permission extends ValueObject_1.ValueObject { + static create(action, subject) { + return new Permission({ action, subject }); + } + get action() { return this.props.action; } + get subject() { return this.props.subject; } +} +exports.Permission = Permission; diff --git a/dist/modules/auth/domain/value-objects/Role.js b/dist/modules/auth/domain/value-objects/Role.js new file mode 100644 index 0000000..08996d9 --- /dev/null +++ b/dist/modules/auth/domain/value-objects/Role.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Role = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Role extends ValueObject_1.ValueObject { + static create(value) { + if (!Role.VALID_ROLES.includes(value)) { + throw new Error(`Invalid role: ${value}. Must be one of: ${Role.VALID_ROLES.join(', ')}`); + } + return new Role({ value: value }); + } + static owner() { return new Role({ value: 'owner' }); } + static admin() { return new Role({ value: 'admin' }); } + static member() { return new Role({ value: 'member' }); } + static viewer() { return new Role({ value: 'viewer' }); } + get value() { + return this.props.value; + } + isOwner() { return this.props.value === 'owner'; } + isAdmin() { return this.props.value === 'admin'; } + isMember() { return this.props.value === 'member'; } + isViewer() { return this.props.value === 'viewer'; } +} +exports.Role = Role; +Role.OWNER = 'owner'; +Role.ADMIN = 'admin'; +Role.MEMBER = 'member'; +Role.VIEWER = 'viewer'; +Role.VALID_ROLES = ['owner', 'admin', 'member', 'viewer']; diff --git a/dist/modules/auth/index.js b/dist/modules/auth/index.js new file mode 100644 index 0000000..9d0d38e --- /dev/null +++ b/dist/modules/auth/index.js @@ -0,0 +1,48 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAuthController = exports.KyselySessionRepository = exports.KyselyApiKeyRepository = exports.KyselyOrganizationRepository = exports.KyselyUserRepository = exports.defineAbilityFor = exports.verifyPassword = exports.hashPassword = exports.requirePermission = exports.createAuthMiddleware = exports.ListOrgMembersQuery = exports.GetUserQuery = exports.CreateApiKeyCommand = exports.InviteMemberCommand = exports.CreateOrganizationCommand = exports.LoginCommand = exports.RegisterCommand = exports.Permission = exports.Role = exports.Email = exports.ApiKey = exports.Organization = exports.User = void 0; +var User_1 = require("./domain/entities/User"); +Object.defineProperty(exports, "User", { enumerable: true, get: function () { return User_1.User; } }); +var Organization_1 = require("./domain/entities/Organization"); +Object.defineProperty(exports, "Organization", { enumerable: true, get: function () { return Organization_1.Organization; } }); +var ApiKey_1 = require("./domain/entities/ApiKey"); +Object.defineProperty(exports, "ApiKey", { enumerable: true, get: function () { return ApiKey_1.ApiKey; } }); +var Email_1 = require("./domain/value-objects/Email"); +Object.defineProperty(exports, "Email", { enumerable: true, get: function () { return Email_1.Email; } }); +var Role_1 = require("./domain/value-objects/Role"); +Object.defineProperty(exports, "Role", { enumerable: true, get: function () { return Role_1.Role; } }); +var Permission_1 = require("./domain/value-objects/Permission"); +Object.defineProperty(exports, "Permission", { enumerable: true, get: function () { return Permission_1.Permission; } }); +var RegisterCommand_1 = require("./application/commands/RegisterCommand"); +Object.defineProperty(exports, "RegisterCommand", { enumerable: true, get: function () { return RegisterCommand_1.RegisterCommand; } }); +var LoginCommand_1 = require("./application/commands/LoginCommand"); +Object.defineProperty(exports, "LoginCommand", { enumerable: true, get: function () { return LoginCommand_1.LoginCommand; } }); +var CreateOrganizationCommand_1 = require("./application/commands/CreateOrganizationCommand"); +Object.defineProperty(exports, "CreateOrganizationCommand", { enumerable: true, get: function () { return CreateOrganizationCommand_1.CreateOrganizationCommand; } }); +var InviteMemberCommand_1 = require("./application/commands/InviteMemberCommand"); +Object.defineProperty(exports, "InviteMemberCommand", { enumerable: true, get: function () { return InviteMemberCommand_1.InviteMemberCommand; } }); +var CreateApiKeyCommand_1 = require("./application/commands/CreateApiKeyCommand"); +Object.defineProperty(exports, "CreateApiKeyCommand", { enumerable: true, get: function () { return CreateApiKeyCommand_1.CreateApiKeyCommand; } }); +var GetUserQuery_1 = require("./application/queries/GetUserQuery"); +Object.defineProperty(exports, "GetUserQuery", { enumerable: true, get: function () { return GetUserQuery_1.GetUserQuery; } }); +var ListOrgMembersQuery_1 = require("./application/queries/ListOrgMembersQuery"); +Object.defineProperty(exports, "ListOrgMembersQuery", { enumerable: true, get: function () { return ListOrgMembersQuery_1.ListOrgMembersQuery; } }); +var AuthMiddleware_1 = require("./application/middleware/AuthMiddleware"); +Object.defineProperty(exports, "createAuthMiddleware", { enumerable: true, get: function () { return AuthMiddleware_1.createAuthMiddleware; } }); +var RBACMiddleware_1 = require("./application/middleware/RBACMiddleware"); +Object.defineProperty(exports, "requirePermission", { enumerable: true, get: function () { return RBACMiddleware_1.requirePermission; } }); +var PasswordService_1 = require("./infrastructure/auth/PasswordService"); +Object.defineProperty(exports, "hashPassword", { enumerable: true, get: function () { return PasswordService_1.hashPassword; } }); +Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: function () { return PasswordService_1.verifyPassword; } }); +var AbilityFactory_1 = require("./infrastructure/casl/AbilityFactory"); +Object.defineProperty(exports, "defineAbilityFor", { enumerable: true, get: function () { return AbilityFactory_1.defineAbilityFor; } }); +var KyselyUserRepository_1 = require("./infrastructure/repositories/KyselyUserRepository"); +Object.defineProperty(exports, "KyselyUserRepository", { enumerable: true, get: function () { return KyselyUserRepository_1.KyselyUserRepository; } }); +var KyselyOrganizationRepository_1 = require("./infrastructure/repositories/KyselyOrganizationRepository"); +Object.defineProperty(exports, "KyselyOrganizationRepository", { enumerable: true, get: function () { return KyselyOrganizationRepository_1.KyselyOrganizationRepository; } }); +var KyselyApiKeyRepository_1 = require("./infrastructure/repositories/KyselyApiKeyRepository"); +Object.defineProperty(exports, "KyselyApiKeyRepository", { enumerable: true, get: function () { return KyselyApiKeyRepository_1.KyselyApiKeyRepository; } }); +var KyselySessionRepository_1 = require("./infrastructure/repositories/KyselySessionRepository"); +Object.defineProperty(exports, "KyselySessionRepository", { enumerable: true, get: function () { return KyselySessionRepository_1.KyselySessionRepository; } }); +var AuthController_1 = require("./infrastructure/http/AuthController"); +Object.defineProperty(exports, "createAuthController", { enumerable: true, get: function () { return AuthController_1.createAuthController; } }); diff --git a/dist/modules/auth/infrastructure/auth/PasswordService.js b/dist/modules/auth/infrastructure/auth/PasswordService.js new file mode 100644 index 0000000..4a502c7 --- /dev/null +++ b/dist/modules/auth/infrastructure/auth/PasswordService.js @@ -0,0 +1,14 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.hashPassword = hashPassword; +exports.verifyPassword = verifyPassword; +const argon2_1 = __importDefault(require("argon2")); +async function hashPassword(password) { + return argon2_1.default.hash(password); +} +async function verifyPassword(password, hash) { + return argon2_1.default.verify(hash, password); +} diff --git a/dist/modules/auth/infrastructure/casl/AbilityFactory.js b/dist/modules/auth/infrastructure/casl/AbilityFactory.js new file mode 100644 index 0000000..726d6a6 --- /dev/null +++ b/dist/modules/auth/infrastructure/casl/AbilityFactory.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.defineAbilityFor = defineAbilityFor; +const ability_1 = require("@casl/ability"); +function defineAbilityFor(role) { + const { can, cannot, build } = new ability_1.AbilityBuilder(ability_1.createMongoAbility); + switch (role) { + case 'owner': + can('manage', 'all'); + break; + case 'admin': + can('manage', 'all'); + cannot('delete', 'Organization'); + cannot('manage', 'License'); + can('read', 'License'); + break; + case 'member': + can('create', ['Session', 'Finding', 'Report']); + can('read', 'all'); + can('update', 'Finding'); + break; + case 'viewer': + can('read', 'all'); + break; + default: + break; + } + return build(); +} diff --git a/dist/modules/auth/infrastructure/http/AuthController.js b/dist/modules/auth/infrastructure/http/AuthController.js new file mode 100644 index 0000000..71dd515 --- /dev/null +++ b/dist/modules/auth/infrastructure/http/AuthController.js @@ -0,0 +1,170 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAuthController = createAuthController; +const express_1 = require("express"); +const AuthMiddleware_1 = require("../../application/middleware/AuthMiddleware"); +function createAuthController(registerCommand, loginCommand, createOrgCommand, inviteMemberCommand, createApiKeyCommand, getUserQuery, listOrgMembersQuery, sessionRepository, apiKeyRepository, userRepository) { + const router = (0, express_1.Router)(); + const authMiddleware = (0, AuthMiddleware_1.createAuthMiddleware)(userRepository, sessionRepository, apiKeyRepository); + // POST /api/auth/register + router.post('/register', async (req, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + const count = await userRepository.count(); + res.json({ required: count === 0 }); + }); + // POST /api/auth/setup — first-run setup + router.post('/setup', async (req, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + 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, res) => { + 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; +} diff --git a/dist/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.js b/dist/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.js new file mode 100644 index 0000000..f351427 --- /dev/null +++ b/dist/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.js @@ -0,0 +1,75 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyApiKeyRepository = void 0; +const ApiKey_1 = require("../../domain/entities/ApiKey"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +class KyselyApiKeyRepository { + constructor(db) { + this.db = db; + } + async save(apiKey) { + 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) { + const row = await this.db + .selectFrom('api_keys') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findByHash(keyHash) { + const row = await this.db + .selectFrom('api_keys') + .selectAll() + .where('key_hash', '=', keyHash) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async listByUser(userId) { + const rows = await this.db + .selectFrom('api_keys') + .selectAll() + .where('user_id', '=', userId) + .execute(); + return rows.map((r) => this.toDomain(r)); + } + async delete(id) { + await this.db.deleteFrom('api_keys').where('id', '=', id).execute(); + } + async updateLastUsed(id, lastUsedAt) { + await this.db + .updateTable('api_keys') + .set({ last_used_at: lastUsedAt.getTime() }) + .where('id', '=', id) + .execute(); + } + toDomain(row) { + return ApiKey_1.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), + 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_1.UniqueId.from(row.id)); + } +} +exports.KyselyApiKeyRepository = KyselyApiKeyRepository; diff --git a/dist/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.js b/dist/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.js new file mode 100644 index 0000000..35f24b5 --- /dev/null +++ b/dist/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.js @@ -0,0 +1,98 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyOrganizationRepository = void 0; +const Organization_1 = require("../../domain/entities/Organization"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +class KyselyOrganizationRepository { + constructor(db) { + this.db = db; + } + async save(org) { + 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) { + const row = await this.db + .selectFrom('organizations') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findBySlug(slug) { + const row = await this.db + .selectFrom('organizations') + .selectAll() + .where('slug', '=', slug) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findAll() { + const rows = await this.db.selectFrom('organizations').selectAll().execute(); + return rows.map((r) => this.toDomain(r)); + } + async addMember(member) { + 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, userId) { + 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) { + 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, userId, role) { + await this.db + .updateTable('org_members') + .set({ role }) + .where('org_id', '=', orgId) + .where('user_id', '=', userId) + .execute(); + } + async removeMember(orgId, userId) { + await this.db + .deleteFrom('org_members') + .where('org_id', '=', orgId) + .where('user_id', '=', userId) + .execute(); + } + toDomain(row) { + return Organization_1.Organization.reconstitute({ name: row.name, slug: row.slug, createdAt: new Date(row.created_at) }, UniqueId_1.UniqueId.from(row.id)); + } +} +exports.KyselyOrganizationRepository = KyselyOrganizationRepository; diff --git a/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js b/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js new file mode 100644 index 0000000..c858841 --- /dev/null +++ b/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js @@ -0,0 +1,46 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselySessionRepository = void 0; +class KyselySessionRepository { + constructor(db) { + this.db = db; + } + async save(session) { + 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) { + 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) { + await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute(); + } + async deleteExpired() { + await this.db + .deleteFrom('auth_sessions') + .where('expires_at', '<', Date.now()) + .execute(); + } +} +exports.KyselySessionRepository = KyselySessionRepository; diff --git a/dist/modules/auth/infrastructure/repositories/KyselyUserRepository.js b/dist/modules/auth/infrastructure/repositories/KyselyUserRepository.js new file mode 100644 index 0000000..339d998 --- /dev/null +++ b/dist/modules/auth/infrastructure/repositories/KyselyUserRepository.js @@ -0,0 +1,73 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyUserRepository = void 0; +const User_1 = require("../../domain/entities/User"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const Email_1 = require("../../domain/value-objects/Email"); +const Role_1 = require("../../domain/value-objects/Role"); +class KyselyUserRepository { + constructor(db) { + this.db = db; + } + async save(user) { + 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) { + const row = await this.db + .selectFrom('users') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findByEmail(email) { + const row = await this.db + .selectFrom('users') + .selectAll() + .where('email', '=', email.toLowerCase()) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findAll() { + const rows = await this.db.selectFrom('users').selectAll().execute(); + return rows.map((r) => this.toDomain(r)); + } + async count() { + const result = await this.db + .selectFrom('users') + .select((eb) => eb.fn.count('id').as('count')) + .executeTakeFirstOrThrow(); + return Number(result.count); + } + toDomain(row) { + return User_1.User.reconstitute({ + email: Email_1.Email.create(row.email), + name: row.name, + passwordHash: row.password_hash, + role: Role_1.Role.create(row.role), + orgId: row.org_id ?? undefined, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }, UniqueId_1.UniqueId.from(row.id)); + } +} +exports.KyselyUserRepository = KyselyUserRepository; diff --git a/package-lock.json b/package-lock.json index 7c1f78c..20073f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,17 @@ "license": "MIT", "dependencies": { "@axe-core/playwright": "^4.11.1", + "@casl/ability": "^6.8.0", "@playwright/test": "^1.40.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", + "argon2": "^0.44.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -36,6 +39,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/supertest": "^7.2.0", @@ -555,6 +559,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@casl/ability": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz", + "integrity": "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==", + "license": "MIT", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -589,6 +605,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1494,6 +1516,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1650,6 +1681,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -1869,6 +1910,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==", + "license": "Apache-2.0" + }, + "node_modules/@ucast/js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.1.0.tgz", + "integrity": "sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "1.10.2" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.4.1.tgz", + "integrity": "sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA==", + "license": "Apache-2.0", + "dependencies": { + "@ucast/core": "1.10.2", + "@ucast/js": "3.1.0", + "@ucast/mongo": "2.4.3" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1971,6 +2047,22 @@ "dev": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2619,6 +2711,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -2681,11 +2792,27 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3845,7 +3972,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4911,6 +5037,15 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-cron": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", @@ -4920,6 +5055,17 @@ "node": ">=6.0.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5131,7 +5277,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5811,7 +5956,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5824,7 +5968,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6670,7 +6813,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index c8f6466..da2e17b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "license": "MIT", "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/cookie-parser": "^1.4.10", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/supertest": "^7.2.0", @@ -36,14 +37,17 @@ }, "dependencies": { "@axe-core/playwright": "^4.11.1", + "@casl/ability": "^6.8.0", "@playwright/test": "^1.40.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", + "argon2": "^0.44.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", diff --git a/src/api/router.ts b/src/api/router.ts index 94c5a8f..52cbb6d 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -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)); diff --git a/src/api/server.ts b/src/api/server.ts index 5d6becb..75f0848 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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) => { diff --git a/src/db/migrations/004_auth_tables.ts b/src/db/migrations/004_auth_tables.ts new file mode 100644 index 0000000..9655907 --- /dev/null +++ b/src/db/migrations/004_auth_tables.ts @@ -0,0 +1,86 @@ +import { Kysely } from 'kysely'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + 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): Promise { + 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(); +} diff --git a/src/main.ts b/src/main.ts index edac945..fc37e75 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { 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 { 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); diff --git a/src/modules/auth/application/commands/CreateApiKeyCommand.ts b/src/modules/auth/application/commands/CreateApiKeyCommand.ts new file mode 100644 index 0000000..ee26edd --- /dev/null +++ b/src/modules/auth/application/commands/CreateApiKeyCommand.ts @@ -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 { + constructor( + private readonly apiKeyRepository: IApiKeyRepository, + private readonly userRepository: IUserRepository + ) {} + + async execute(request: CreateApiKeyRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/commands/CreateOrganizationCommand.ts b/src/modules/auth/application/commands/CreateOrganizationCommand.ts new file mode 100644 index 0000000..cc53013 --- /dev/null +++ b/src/modules/auth/application/commands/CreateOrganizationCommand.ts @@ -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 { + constructor( + private readonly orgRepository: IOrganizationRepository, + private readonly userRepository: IUserRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: CreateOrganizationRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/commands/InviteMemberCommand.ts b/src/modules/auth/application/commands/InviteMemberCommand.ts new file mode 100644 index 0000000..b99ba71 --- /dev/null +++ b/src/modules/auth/application/commands/InviteMemberCommand.ts @@ -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 { + constructor( + private readonly orgRepository: IOrganizationRepository, + private readonly userRepository: IUserRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: InviteMemberRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/commands/LoginCommand.ts b/src/modules/auth/application/commands/LoginCommand.ts new file mode 100644 index 0000000..1fa4c79 --- /dev/null +++ b/src/modules/auth/application/commands/LoginCommand.ts @@ -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 { + constructor( + private readonly userRepository: IUserRepository, + private readonly sessionRepository: ISessionRepository, + private readonly eventBus: EventBus, + private readonly verifyPassword: (password: string, hash: string) => Promise, + private readonly sessionMaxAgeSeconds: number = 7 * 24 * 60 * 60 + ) {} + + async execute(request: LoginRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/commands/RegisterCommand.ts b/src/modules/auth/application/commands/RegisterCommand.ts new file mode 100644 index 0000000..891499f --- /dev/null +++ b/src/modules/auth/application/commands/RegisterCommand.ts @@ -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 { + constructor( + private readonly userRepository: IUserRepository, + private readonly eventBus: EventBus, + private readonly hashPassword: (password: string) => Promise + ) {} + + async execute(request: RegisterRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/middleware/AuthMiddleware.ts b/src/modules/auth/application/middleware/AuthMiddleware.ts new file mode 100644 index 0000000..4f7eb34 --- /dev/null +++ b/src/modules/auth/application/middleware/AuthMiddleware.ts @@ -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 { + 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' }); + } + }; +} diff --git a/src/modules/auth/application/middleware/RBACMiddleware.ts b/src/modules/auth/application/middleware/RBACMiddleware.ts new file mode 100644 index 0000000..925df86 --- /dev/null +++ b/src/modules/auth/application/middleware/RBACMiddleware.ts @@ -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(); + }; +} diff --git a/src/modules/auth/application/queries/GetUserQuery.ts b/src/modules/auth/application/queries/GetUserQuery.ts new file mode 100644 index 0000000..b2b11fd --- /dev/null +++ b/src/modules/auth/application/queries/GetUserQuery.ts @@ -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 { + constructor(private readonly userRepository: IUserRepository) {} + + async execute(request: GetUserRequest): Promise> { + 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, + }); + } +} diff --git a/src/modules/auth/application/queries/ListOrgMembersQuery.ts b/src/modules/auth/application/queries/ListOrgMembersQuery.ts new file mode 100644 index 0000000..66a5b80 --- /dev/null +++ b/src/modules/auth/application/queries/ListOrgMembersQuery.ts @@ -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 { + constructor( + private readonly orgRepository: IOrganizationRepository, + private readonly userRepository: IUserRepository + ) {} + + async execute(request: ListOrgMembersRequest): Promise> { + 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 }); + } +} diff --git a/src/modules/auth/domain/entities/ApiKey.ts b/src/modules/auth/domain/entities/ApiKey.ts new file mode 100644 index 0000000..535f5b1 --- /dev/null +++ b/src/modules/auth/domain/entities/ApiKey.ts @@ -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 { + static create(props: Omit, 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(); + } +} diff --git a/src/modules/auth/domain/entities/Organization.ts b/src/modules/auth/domain/entities/Organization.ts new file mode 100644 index 0000000..3eb0cf7 --- /dev/null +++ b/src/modules/auth/domain/entities/Organization.ts @@ -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 { + static create(props: Omit, 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; } +} diff --git a/src/modules/auth/domain/entities/User.ts b/src/modules/auth/domain/entities/User.ts new file mode 100644 index 0000000..7a2ccd4 --- /dev/null +++ b/src/modules/auth/domain/entities/User.ts @@ -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 { + static create(props: Omit, 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(); + } +} diff --git a/src/modules/auth/domain/events/MemberInvited.ts b/src/modules/auth/domain/events/MemberInvited.ts new file mode 100644 index 0000000..6c9e3fd --- /dev/null +++ b/src/modules/auth/domain/events/MemberInvited.ts @@ -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 + ) {} +} diff --git a/src/modules/auth/domain/events/OrgCreated.ts b/src/modules/auth/domain/events/OrgCreated.ts new file mode 100644 index 0000000..17bd754 --- /dev/null +++ b/src/modules/auth/domain/events/OrgCreated.ts @@ -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 + ) {} +} diff --git a/src/modules/auth/domain/events/UserCreated.ts b/src/modules/auth/domain/events/UserCreated.ts new file mode 100644 index 0000000..5d607ac --- /dev/null +++ b/src/modules/auth/domain/events/UserCreated.ts @@ -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 + ) {} +} diff --git a/src/modules/auth/domain/events/UserLoggedIn.ts b/src/modules/auth/domain/events/UserLoggedIn.ts new file mode 100644 index 0000000..34da40a --- /dev/null +++ b/src/modules/auth/domain/events/UserLoggedIn.ts @@ -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 + ) {} +} diff --git a/src/modules/auth/domain/ports/IApiKeyRepository.ts b/src/modules/auth/domain/ports/IApiKeyRepository.ts new file mode 100644 index 0000000..91dbf05 --- /dev/null +++ b/src/modules/auth/domain/ports/IApiKeyRepository.ts @@ -0,0 +1,10 @@ +import { ApiKey } from '../entities/ApiKey'; + +export interface IApiKeyRepository { + save(apiKey: ApiKey): Promise; + findById(id: string): Promise; + findByHash(keyHash: string): Promise; + listByUser(userId: string): Promise; + delete(id: string): Promise; + updateLastUsed(id: string, lastUsedAt: Date): Promise; +} diff --git a/src/modules/auth/domain/ports/IOrganizationRepository.ts b/src/modules/auth/domain/ports/IOrganizationRepository.ts new file mode 100644 index 0000000..e2b8d8f --- /dev/null +++ b/src/modules/auth/domain/ports/IOrganizationRepository.ts @@ -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; + findById(id: string): Promise; + findBySlug(slug: string): Promise; + findAll(): Promise; + addMember(member: OrgMember): Promise; + getMember(orgId: string, userId: string): Promise; + listMembers(orgId: string): Promise; + updateMemberRole(orgId: string, userId: string, role: string): Promise; + removeMember(orgId: string, userId: string): Promise; +} diff --git a/src/modules/auth/domain/ports/ISessionRepository.ts b/src/modules/auth/domain/ports/ISessionRepository.ts new file mode 100644 index 0000000..62877e2 --- /dev/null +++ b/src/modules/auth/domain/ports/ISessionRepository.ts @@ -0,0 +1,14 @@ +export interface AuthSession { + id: string; + userId: string; + token: string; + expiresAt: Date; + createdAt: Date; +} + +export interface ISessionRepository { + save(session: AuthSession): Promise; + findByToken(token: string): Promise; + deleteByToken(token: string): Promise; + deleteExpired(): Promise; +} diff --git a/src/modules/auth/domain/ports/IUserRepository.ts b/src/modules/auth/domain/ports/IUserRepository.ts new file mode 100644 index 0000000..d982b37 --- /dev/null +++ b/src/modules/auth/domain/ports/IUserRepository.ts @@ -0,0 +1,9 @@ +import { User } from '../entities/User'; + +export interface IUserRepository { + save(user: User): Promise; + findById(id: string): Promise; + findByEmail(email: string): Promise; + findAll(): Promise; + count(): Promise; +} diff --git a/src/modules/auth/domain/value-objects/Email.ts b/src/modules/auth/domain/value-objects/Email.ts new file mode 100644 index 0000000..f063e30 --- /dev/null +++ b/src/modules/auth/domain/value-objects/Email.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface EmailProps { + value: string; +} + +export class Email extends ValueObject { + 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; + } +} diff --git a/src/modules/auth/domain/value-objects/Permission.ts b/src/modules/auth/domain/value-objects/Permission.ts new file mode 100644 index 0000000..1c52384 --- /dev/null +++ b/src/modules/auth/domain/value-objects/Permission.ts @@ -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 { + 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; } +} diff --git a/src/modules/auth/domain/value-objects/Role.ts b/src/modules/auth/domain/value-objects/Role.ts new file mode 100644 index 0000000..b612175 --- /dev/null +++ b/src/modules/auth/domain/value-objects/Role.ts @@ -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 { + 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'; } +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..97eb9ca --- /dev/null +++ b/src/modules/auth/index.ts @@ -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'; diff --git a/src/modules/auth/infrastructure/auth/PasswordService.ts b/src/modules/auth/infrastructure/auth/PasswordService.ts new file mode 100644 index 0000000..076e755 --- /dev/null +++ b/src/modules/auth/infrastructure/auth/PasswordService.ts @@ -0,0 +1,9 @@ +import argon2 from 'argon2'; + +export async function hashPassword(password: string): Promise { + return argon2.hash(password); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return argon2.verify(hash, password); +} diff --git a/src/modules/auth/infrastructure/casl/AbilityFactory.ts b/src/modules/auth/infrastructure/casl/AbilityFactory.ts new file mode 100644 index 0000000..5efc282 --- /dev/null +++ b/src/modules/auth/infrastructure/casl/AbilityFactory.ts @@ -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(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(); +} diff --git a/src/modules/auth/infrastructure/http/AuthController.ts b/src/modules/auth/infrastructure/http/AuthController.ts new file mode 100644 index 0000000..5741208 --- /dev/null +++ b/src/modules/auth/infrastructure/http/AuthController.ts @@ -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; +} diff --git a/src/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.ts b/src/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.ts new file mode 100644 index 0000000..7126db8 --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/KyselyApiKeyRepository.ts @@ -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) {} + + async save(apiKey: ApiKey): Promise { + 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 { + const row = await this.db + .selectFrom('api_keys') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findByHash(keyHash: string): Promise { + 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 { + 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 { + await this.db.deleteFrom('api_keys').where('id', '=', id).execute(); + } + + async updateLastUsed(id: string, lastUsedAt: Date): Promise { + 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) + ); + } +} diff --git a/src/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.ts b/src/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.ts new file mode 100644 index 0000000..f6ba0e3 --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/KyselyOrganizationRepository.ts @@ -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) {} + + async save(org: Organization): Promise { + 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 { + const row = await this.db + .selectFrom('organizations') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findBySlug(slug: string): Promise { + const row = await this.db + .selectFrom('organizations') + .selectAll() + .where('slug', '=', slug) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findAll(): Promise { + const rows = await this.db.selectFrom('organizations').selectAll().execute(); + return rows.map((r) => this.toDomain(r)); + } + + async addMember(member: OrgMember): Promise { + 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 { + 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 { + 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 { + await this.db + .updateTable('org_members') + .set({ role }) + .where('org_id', '=', orgId) + .where('user_id', '=', userId) + .execute(); + } + + async removeMember(orgId: string, userId: string): Promise { + 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) + ); + } +} diff --git a/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts b/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts new file mode 100644 index 0000000..934c539 --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts @@ -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) {} + + async save(session: AuthSession): Promise { + 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 { + 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 { + await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute(); + } + + async deleteExpired(): Promise { + await this.db + .deleteFrom('auth_sessions') + .where('expires_at', '<', Date.now()) + .execute(); + } +} diff --git a/src/modules/auth/infrastructure/repositories/KyselyUserRepository.ts b/src/modules/auth/infrastructure/repositories/KyselyUserRepository.ts new file mode 100644 index 0000000..a839789 --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/KyselyUserRepository.ts @@ -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) {} + + async save(user: User): Promise { + 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 { + const row = await this.db + .selectFrom('users') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findByEmail(email: string): Promise { + const row = await this.db + .selectFrom('users') + .selectAll() + .where('email', '=', email.toLowerCase()) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findAll(): Promise { + const rows = await this.db.selectFrom('users').selectAll().execute(); + return rows.map((r) => this.toDomain(r)); + } + + async count(): Promise { + 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) + ); + } +} diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts index d047193..0d6b152 100644 --- a/src/shared/infrastructure/DatabaseConnection.ts +++ b/src/shared/infrastructure/DatabaseConnection.ts @@ -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 { diff --git a/tests/modules/auth.test.ts b/tests/modules/auth.test.ts new file mode 100644 index 0000000..e748828 --- /dev/null +++ b/tests/modules/auth.test.ts @@ -0,0 +1,300 @@ +import { User } from '../../src/modules/auth/domain/entities/User'; +import { Organization } from '../../src/modules/auth/domain/entities/Organization'; +import { Email } from '../../src/modules/auth/domain/value-objects/Email'; +import { Role } from '../../src/modules/auth/domain/value-objects/Role'; +import { RegisterCommand } from '../../src/modules/auth/application/commands/RegisterCommand'; +import { LoginCommand } from '../../src/modules/auth/application/commands/LoginCommand'; +import { IUserRepository } from '../../src/modules/auth/domain/ports/IUserRepository'; +import { ISessionRepository, AuthSession } from '../../src/modules/auth/domain/ports/ISessionRepository'; +import { EventBus } from '../../src/shared/application/EventBus'; +import { DomainEvent } from '../../src/shared/domain/DomainEvent'; +import { EventHandler } from '../../src/shared/application/EventHandler'; +import { defineAbilityFor } from '../../src/modules/auth/infrastructure/casl/AbilityFactory'; + +// ─── Mock EventBus ───────────────────────────────────────────────────────────── + +class MockEventBus implements EventBus { + published: DomainEvent[] = []; + async publish(event: DomainEvent): Promise { this.published.push(event); } + subscribe(_name: string, _handler: EventHandler): void {} +} + +// ─── Mock User Repository ────────────────────────────────────────────────────── + +class InMemoryUserRepository implements IUserRepository { + private store = new Map(); + + async save(user: User): Promise { + this.store.set(user.id.toString(), user); + } + async findById(id: string): Promise { + return this.store.get(id); + } + async findByEmail(email: string): Promise { + return Array.from(this.store.values()).find(u => u.email.value === email); + } + async findAll(): Promise { + return Array.from(this.store.values()); + } + async count(): Promise { + return this.store.size; + } +} + +// ─── Mock Session Repository ─────────────────────────────────────────────────── + +class InMemorySessionRepository implements ISessionRepository { + private store = new Map(); + + async save(session: AuthSession): Promise { + this.store.set(session.token, session); + } + async findByToken(token: string): Promise { + return this.store.get(token); + } + async deleteByToken(token: string): Promise { + this.store.delete(token); + } + async deleteExpired(): Promise { + const now = new Date(); + for (const [token, session] of this.store) { + if (session.expiresAt < now) this.store.delete(token); + } + } +} + +// ─── Tests: Email Value Object ───────────────────────────────────────────────── + +describe('Email value object', () => { + it('creates valid email', () => { + const email = Email.create('Test@Example.COM'); + expect(email.value).toBe('test@example.com'); + }); + + it('throws for invalid email', () => { + expect(() => Email.create('not-an-email')).toThrow('Invalid email address'); + }); + + it('equals by value', () => { + const a = Email.create('user@example.com'); + const b = Email.create('user@example.com'); + expect(a.equals(b)).toBe(true); + }); +}); + +// ─── Tests: Role Value Object ────────────────────────────────────────────────── + +describe('Role value object', () => { + it('creates valid roles', () => { + expect(Role.owner().value).toBe('owner'); + expect(Role.admin().value).toBe('admin'); + expect(Role.member().value).toBe('member'); + expect(Role.viewer().value).toBe('viewer'); + }); + + it('throws for invalid role', () => { + expect(() => Role.create('superadmin')).toThrow('Invalid role'); + }); + + it('checks role type', () => { + expect(Role.owner().isOwner()).toBe(true); + expect(Role.admin().isAdmin()).toBe(true); + expect(Role.member().isMember()).toBe(true); + expect(Role.viewer().isViewer()).toBe(true); + }); +}); + +// ─── Tests: User Aggregate ───────────────────────────────────────────────────── + +describe('User aggregate', () => { + it('creates user and emits UserCreated event', () => { + const user = User.create({ + email: Email.create('alice@example.com'), + name: 'Alice', + passwordHash: 'hash', + role: Role.owner(), + }); + + expect(user.email.value).toBe('alice@example.com'); + expect(user.name).toBe('Alice'); + expect(user.role.isOwner()).toBe(true); + expect(user.domainEvents).toHaveLength(1); + expect(user.domainEvents[0]!.eventName).toBe('auth.user.created'); + }); + + it('assigns user to org', () => { + const user = User.create({ + email: Email.create('bob@example.com'), + name: 'Bob', + passwordHash: 'hash', + role: Role.member(), + }); + user.assignToOrg('org-123'); + expect(user.orgId).toBe('org-123'); + }); +}); + +// ─── Tests: Organization Aggregate ──────────────────────────────────────────── + +describe('Organization aggregate', () => { + it('creates org with slug and emits OrgCreated', () => { + const org = Organization.create({ name: 'Acme Corp', slug: 'acme-corp' }); + expect(org.name).toBe('Acme Corp'); + expect(org.slug).toBe('acme-corp'); + expect(org.domainEvents[0]!.eventName).toBe('auth.org.created'); + }); + + it('slugifies name correctly', () => { + expect(Organization.slugify('My Awesome Org!')).toBe('my-awesome-org'); + expect(Organization.slugify(' hello world ')).toBe('hello-world'); + }); +}); + +// ─── Tests: RegisterCommand ──────────────────────────────────────────────────── + +describe('RegisterCommand', () => { + let userRepo: InMemoryUserRepository; + let eventBus: MockEventBus; + let registerCommand: RegisterCommand; + + beforeEach(() => { + userRepo = new InMemoryUserRepository(); + eventBus = new MockEventBus(); + registerCommand = new RegisterCommand(userRepo, eventBus, async (p) => `hash:${p}`); + }); + + it('registers a new user successfully', async () => { + const result = await registerCommand.execute({ + email: 'alice@example.com', + password: 'password123', + name: 'Alice', + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.email).toBe('alice@example.com'); + expect(result.value.role).toBe('member'); + } + expect(await userRepo.count()).toBe(1); + expect(eventBus.published).toHaveLength(1); + expect(eventBus.published[0]!.eventName).toBe('auth.user.created'); + }); + + it('fails if email already registered', async () => { + await registerCommand.execute({ email: 'alice@example.com', password: 'pass1234', name: 'Alice' }); + const result = await registerCommand.execute({ email: 'alice@example.com', password: 'pass5678', name: 'Alice2' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('already registered'); + }); + + it('fails if password too short', async () => { + const result = await registerCommand.execute({ email: 'bob@test.com', password: 'short', name: 'Bob' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('8 characters'); + }); + + it('fails with invalid email', async () => { + const result = await registerCommand.execute({ email: 'not-an-email', password: 'password123', name: 'Bob' }); + expect(result.ok).toBe(false); + }); + + it('registers with custom role', async () => { + const result = await registerCommand.execute({ + email: 'admin@example.com', + password: 'password123', + name: 'Admin', + role: 'owner', + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.role).toBe('owner'); + }); +}); + +// ─── Tests: LoginCommand ─────────────────────────────────────────────────────── + +describe('LoginCommand', () => { + let userRepo: InMemoryUserRepository; + let sessionRepo: InMemorySessionRepository; + let eventBus: MockEventBus; + let registerCommand: RegisterCommand; + let loginCommand: LoginCommand; + + beforeEach(async () => { + userRepo = new InMemoryUserRepository(); + sessionRepo = new InMemorySessionRepository(); + eventBus = new MockEventBus(); + registerCommand = new RegisterCommand(userRepo, eventBus, async (p) => `hash:${p}`); + loginCommand = new LoginCommand( + userRepo, + sessionRepo, + eventBus, + async (password, hash) => hash === `hash:${password}` + ); + + await registerCommand.execute({ email: 'alice@example.com', password: 'password123', name: 'Alice' }); + eventBus.published = []; + }); + + it('logs in with correct credentials', async () => { + const result = await loginCommand.execute({ email: 'alice@example.com', password: 'password123' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.sessionToken).toBeTruthy(); + expect(result.value.role).toBe('member'); + } + expect(eventBus.published[0]!.eventName).toBe('auth.user.logged_in'); + }); + + it('fails with wrong password', async () => { + const result = await loginCommand.execute({ email: 'alice@example.com', password: 'wrongpass' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('Invalid credentials'); + }); + + it('fails with unknown email', async () => { + const result = await loginCommand.execute({ email: 'nobody@example.com', password: 'password123' }); + expect(result.ok).toBe(false); + }); + + it('creates session in repository', async () => { + const result = await loginCommand.execute({ email: 'alice@example.com', password: 'password123' }); + expect(result.ok).toBe(true); + if (result.ok) { + const session = await sessionRepo.findByToken(result.value.sessionToken); + expect(session).toBeDefined(); + expect(session!.userId).toBeTruthy(); + } + }); +}); + +// ─── Tests: CASL AbilityFactory ──────────────────────────────────────────────── + +describe('CASL AbilityFactory', () => { + it('owner can manage all', () => { + const ability = defineAbilityFor('owner'); + expect(ability.can('manage', 'all')).toBe(true); + }); + + it('admin can manage sessions but not delete org or manage license', () => { + const ability = defineAbilityFor('admin'); + expect(ability.can('create', 'Session')).toBe(true); + expect(ability.can('delete', 'Organization')).toBe(false); + expect(ability.can('manage', 'License')).toBe(false); + expect(ability.can('read', 'License')).toBe(true); + }); + + it('member can create sessions/findings/reports and read all', () => { + const ability = defineAbilityFor('member'); + expect(ability.can('create', 'Session')).toBe(true); + expect(ability.can('read', 'Session')).toBe(true); + expect(ability.can('delete', 'Session')).toBe(false); + expect(ability.can('update', 'Finding')).toBe(true); + }); + + it('viewer can only read', () => { + const ability = defineAbilityFor('viewer'); + expect(ability.can('read', 'Session')).toBe(true); + expect(ability.can('create', 'Session')).toBe(false); + expect(ability.can('delete', 'Finding')).toBe(false); + }); +});