feat(auth): GET /auth/module-identity — identity + module gate

Adds the route (cookie auth via verifyAccessToken, module gate seqid 31,
X-Auth-* headers, admin via seqid set). Adds a jest setupFiles hook giving a
dummy DUC_URL for module-load-time env reads, which also unblocks the
pre-existing authentication.guard.spec.

Co-Authored-By: WOZCODE <contact@withwoz.com>
This commit is contained in:
Rafael
2026-08-20 10:09:31 -03:00
co-authored by WOZCODE
parent 15b5c49fb2
commit a795f543f6
4 changed files with 189 additions and 0 deletions
+3
View File
@@ -5,6 +5,9 @@ const config: Config.InitialOptions = {
roots: ['<rootDir>/src/', '<rootDir>/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: ['<rootDir>/test/jest.setup-env.ts'],
collectCoverageFrom: ['**/*.[jt]s'],
coverageDirectory: 'coverage',
coveragePathIgnorePatterns: [
+46
View File
@@ -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();
}
}
@@ -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<string, string> = {};
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<string, string> = {}) {
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<AuthController> {
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');
});
});
+13
View File
@@ -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';