mirror of
https://github.com/dadosfera/maestro.git
synced 2026-08-31 19:58:21 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user