diff --git a/jest.config.ts b/jest.config.ts index 9442e04..0a1bbc1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,6 +5,9 @@ const config: Config.InitialOptions = { roots: ['/src/', '/test/'], testRegex: '.*\\.(test|spec)\\.[jt]s$', transform: { '\\.[jt]s$': 'ts-jest' }, + // Dummy values for env vars read at module-import time (see the setup file), + // so specs importing those modules don't crash on load. + setupFiles: ['/test/jest.setup-env.ts'], collectCoverageFrom: ['**/*.[jt]s'], coverageDirectory: 'coverage', coveragePathIgnorePatterns: [ diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 40eaad9..665729f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -32,6 +32,12 @@ import { } from '@dadosfera/protospack-v2/dist/lib/Duc/interfaces/messages'; import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum'; +import { + parseAdminSeqids, + moduleGateSeqid, + isModuleAllowed, + isAdmin, +} from './orchest-identity'; import { Authenticated, RequireAllPermissions, @@ -531,4 +537,44 @@ export class AuthController { return res.status(200).json(user); } } + + /** + * Ingress auth subrequest for Orchest (see dbt-to-orchest + * docs/superpowers/specs/2026-08-20-maestro-module-identity-design.md). + * + * Authenticates the ddf-auth cookie, gates on the module permission, and + * returns the X-Auth-* identity headers orchest-api trusts. Never writes + * cookies (an auth_request response cannot). No token refresh: an expired + * token is a 401 (the signin flow re-auths). + */ + @Get('module-identity') + async moduleIdentity(@Req() req: Request, @Res() res: Response) { + const token = req.cookies?.['ddf-auth']; + if (!token) { + return res.status(401).send(); + } + + let payload: any; + try { + payload = await this.authClient.verifyAccessToken(token); + } catch (e) { + return res.status(401).send(); + } + + const perms: number[] = payload?.permissions ?? []; + const gate = moduleGateSeqid(process.env.ORCHEST_MODULE_PERMISSION_SEQID); + if (!isModuleAllowed(perms, gate)) { + return res.status(403).send(); + } + + res.set('X-Auth-User', String(payload.user_id)); + res.set('X-Auth-Username', String(payload.username ?? '')); + if ( + isAdmin(perms, parseAdminSeqids(process.env.ORCHEST_ADMIN_PERMISSION_SEQIDS)) + ) { + res.set('X-Auth-Roles', 'admin'); + } + + return res.status(200).send(); + } } diff --git a/src/modules/auth/module-identity.controller.spec.ts b/src/modules/auth/module-identity.controller.spec.ts new file mode 100644 index 0000000..7f1830a --- /dev/null +++ b/src/modules/auth/module-identity.controller.spec.ts @@ -0,0 +1,127 @@ +import { Test } from '@nestjs/testing'; +import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; +import { AuthController } from './auth.controller'; +import { AuthClientService } from './auth.service'; +import { ApiKeyService } from 'src/modules/api-key/api-key.service'; + +function res() { + const headers: Record = {}; + const r: any = { + _status: 0, + _sent: undefined, + set: (k: string, v: string) => { + headers[k] = v; + return r; + }, + status: (c: number) => { + r._status = c; + return r; + }, + send: (b?: any) => { + r._sent = b ?? ''; + return r; + }, + json: (b?: any) => { + r._sent = b; + return r; + }, + _headers: headers, + }; + return r; +} + +function req(cookie?: string, query: Record = {}) { + return { cookies: cookie ? { 'ddf-auth': cookie } : {}, query } as any; +} + +const loggerStub = { + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +} as unknown as DadosferaLogger; + +async function makeController(auth: AuthClientService): Promise { + const mod = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: DadosferaLogger, useValue: loggerStub }, + { provide: AuthClientService, useValue: auth }, + { provide: ApiKeyService, useValue: {} }, + ], + }).compile(); + return mod.get(AuthController); +} + +describe('GET /auth/module-identity — identity', () => { + let controller: AuthController; + const auth = { + verifyAccessToken: jest.fn(), + } as unknown as AuthClientService; + + beforeEach(async () => { + jest.resetAllMocks(); + process.env.ORCHEST_MODULE_PERMISSION_SEQID = '31'; + process.env.ORCHEST_ADMIN_PERMISSION_SEQIDS = '34'; + controller = await makeController(auth); + }); + + it('no cookie → 401', async () => { + const r = res(); + await controller.moduleIdentity(req(undefined), r); + expect(r._status).toBe(401); + }); + + it('valid + module + admin → 200 with X-Auth-Roles: admin', async () => { + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-1', + username: 'alice', + permissions: [31, 34], + }); + const r = res(); + await controller.moduleIdentity(req('tok'), r); + expect(r._status).toBe(200); + expect(r._headers['X-Auth-User']).toBe('u-1'); + expect(r._headers['X-Auth-Username']).toBe('alice'); + expect(r._headers['X-Auth-Roles']).toBe('admin'); + }); + + it('valid + module, not admin → 200, no X-Auth-Roles', async () => { + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-2', + username: 'bob', + permissions: [31], + }); + const r = res(); + await controller.moduleIdentity(req('tok'), r); + expect(r._status).toBe(200); + expect(r._headers['X-Auth-Roles']).toBeUndefined(); + }); + + it('valid, lacks module → 403', async () => { + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-3', + username: 'carol', + permissions: [5], + }); + const r = res(); + await controller.moduleIdentity(req('tok'), r); + expect(r._status).toBe(403); + }); + + it('verify throws (expired/bad) → 401', async () => { + (auth.verifyAccessToken as jest.Mock).mockRejectedValue(new Error('bad')); + const r = res(); + await controller.moduleIdentity(req('tok'), r); + expect(r._status).toBe(401); + }); + + it('admin seqids extended by config → 200 admin', async () => { + process.env.ORCHEST_ADMIN_PERMISSION_SEQIDS = '34,99'; + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-4', + username: 'dana', + permissions: [31, 99], + }); + const r = res(); + await controller.moduleIdentity(req('tok'), r); + expect(r._headers['X-Auth-Roles']).toBe('admin'); + }); +}); diff --git a/test/jest.setup-env.ts b/test/jest.setup-env.ts new file mode 100644 index 0000000..81d3f3e --- /dev/null +++ b/test/jest.setup-env.ts @@ -0,0 +1,13 @@ +/** + * Jest setupFiles hook: provide dummy values for env vars that modules read + * at *import time* (module-load side effects), so unit specs that transitively + * import those modules don't crash before a single test runs. + * + * These are never exercised by unit tests — the services that use them are + * stubbed via DI — but the values must exist because the reads happen at + * module evaluation, before any mock is installed. + * + * DUC_URL: read at load time in src/modules/duc/client.config.ts, reached via + * the AuthClientService import chain. + */ +process.env.DUC_URL = process.env.DUC_URL || 'duc:localhost:0';