fix(auth): derive Orchest identity from numeric seqids (JWT carries seqids not claim strings)

The JWT `permissions` claim is an array of numeric seqids at runtime
(see authentication.guard.ts / authentication.decorator.ts), not claim
strings. deriveOrchestIdentity previously matched claim strings against
this numeric array, so roles[]/modules[] were always empty for every
real user.

- deriveOrchestIdentity now takes number[] | undefined and matches
  seqids sourced from PERMISSIONS_GROUPS (permissions.enum.ts) instead
  of hand-copied literals.
- permissions is translated back to claim strings via a full
  seqid->claim catalog built once from PERMISSIONS_GROUPS; unknown
  seqids are dropped (auth-server ignores permissions[] in v1).
- auth.controller.ts's api-key branch literal is now annotated
  `: UserDTO` so tsc enforces the three fields there.
- Both spec files re-fixtured with numeric seqid inputs, including a
  mixed admin+module case and an exact claim-string translation
  assertion.

Co-Authored-By: WOZCODE <contact@withwoz.com>
This commit is contained in:
Rafael
2026-08-24 16:03:20 -03:00
co-authored by WOZCODE
parent a16fefe691
commit a5a685ee3f
4 changed files with 68 additions and 30 deletions
+2 -1
View File
@@ -37,6 +37,7 @@ import {
RequireAllPermissions,
} from 'src/decorators/authentication.decorator';
import { AuthClientService } from './auth.service';
import { UserDTO } from './dtos/login';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { GrpcToHttpExceptionFilter } from '../../error/grpc-to-http-exception.filter';
import { RequestUser, User } from 'src/decorators/user.decorator';
@@ -486,7 +487,7 @@ export class AuthController {
this.logger.info('Authenticating via X-Api-key header');
const { api_key } = await this.apiKeyService.get(apiKey);
const userDto = {
const userDto: UserDTO = {
id: api_key.user_id,
name: api_key.username,
email: api_key.username,
@@ -1,17 +1,19 @@
import { deriveOrchestIdentity } from './orchest-identity';
// The three getMe branches must all yield the three fields. This test
// pins the SHAPE contract without loading the DUC gRPC client.
// pins the SHAPE contract without loading the DUC gRPC client. The JWT
// permissions claim carries numeric seqids at runtime (34 = users:admin,
// 31 = intelligence:open), so fixtures here use numeric seqid arrays.
describe('/auth/me field contract', () => {
it('cookie/refresh path derives from payload permissions', () => {
it('cookie/refresh path derives from payload permissions (numeric seqids)', () => {
const enriched = {
id: 'u', name: 'n', email: 'e',
customer: { id: 'c', name: 'cust', tier: 't' },
...deriveOrchestIdentity(['users:admin', 'intelligence:open']),
...deriveOrchestIdentity([34, 31]),
};
expect(enriched.roles).toContain('super-admin');
expect(enriched.modules).toContain('intelligence');
expect(enriched.permissions).toHaveLength(2);
expect(enriched.permissions).toEqual(['users:admin', 'intelligence:open']);
});
it('api-key branch is empty for all three fields', () => {
+20 -12
View File
@@ -1,29 +1,37 @@
import { deriveOrchestIdentity } from './orchest-identity';
// JWT permissions claim carries numeric seqids (not claim strings) — see
// src/decorators/authentication.decorator.ts and
// src/authentication/authentication.guard.ts. 34 = users:admin,
// 31 = intelligence:open, 43 = process:open.
describe('deriveOrchestIdentity', () => {
it('passes permissions through verbatim', () => {
expect(deriveOrchestIdentity(['intelligence:open']).permissions).toEqual([
it('translates seqids to claim strings in permissions', () => {
expect(deriveOrchestIdentity([31]).permissions).toEqual([
'intelligence:open',
]);
});
it('marks super-admin from users:admin', () => {
expect(deriveOrchestIdentity(['users:admin']).roles).toContain('super-admin');
it('marks super-admin from the users:admin seqid (34)', () => {
expect(deriveOrchestIdentity([34]).roles).toContain('super-admin');
});
it('omits super-admin when users:admin absent', () => {
expect(deriveOrchestIdentity(['intelligence:open']).roles).not.toContain(
'super-admin',
);
it('omits super-admin when the admin seqid is absent', () => {
expect(deriveOrchestIdentity([31]).roles).not.toContain('super-admin');
});
it('maps intelligence:open and process:open to module keys', () => {
const { modules } = deriveOrchestIdentity(['intelligence:open', 'process:open']);
it('maps intelligence and process seqids to module keys', () => {
const { modules } = deriveOrchestIdentity([31, 43]);
expect(modules.sort()).toEqual(['intelligence', 'process']);
});
it('ignores non-module permissions in modules', () => {
expect(deriveOrchestIdentity(['users:admin']).modules).toEqual([]);
it('ignores non-module seqids in modules', () => {
expect(deriveOrchestIdentity([34]).modules).toEqual([]);
});
it('handles a mixed real-shape seqid array (admin + intelligence)', () => {
const { roles, modules } = deriveOrchestIdentity([34, 31]);
expect(roles).toEqual(['super-admin']);
expect(modules).toEqual(['intelligence']);
});
it('handles undefined/empty permissions', () => {
+40 -13
View File
@@ -1,11 +1,29 @@
// Claim → semantic strings. Seqid knowledge stays here, beside the enum
// that owns it (users:admin=34, intelligence:open=31, process:open=43).
const ADMIN_CLAIM = 'users:admin';
const MODULE_CLAIMS: Record<string, string> = {
'intelligence:open': 'intelligence',
'process:open': 'process',
import { PERMISSIONS_GROUPS } from 'src/authentication/permissions.enum';
// The JWT `permissions` claim is an array of numeric seqids (not claim
// strings) — see src/authentication/authentication.guard.ts and
// src/decorators/authentication.decorator.ts. Seqids are sourced from
// PERMISSIONS_GROUPS (the enum that owns them), not hand-copied here.
const ADMIN_SEQID = PERMISSIONS_GROUPS.USERS.permissions.ADMIN.seqid;
const MODULE_SEQID_TO_KEY: Record<number, string> = {
[PERMISSIONS_GROUPS.INTELLIGENCE.permissions.INTELLIGENCE.seqid]:
'intelligence',
[PERMISSIONS_GROUPS.PROCESS.permissions.TRANSFORMATION.seqid]: 'process',
};
// Full seqid -> claim catalog, built once from every group/permission in
// PERMISSIONS_GROUPS, so the `permissions` field can be translated back to
// honest claim strings for UserDTO.
const SEQID_TO_CLAIM: Record<number, string> = Object.values(
PERMISSIONS_GROUPS,
).reduce((catalog, group) => {
for (const permission of Object.values(group.permissions)) {
catalog[permission.seqid] = permission.claim;
}
return catalog;
}, {} as Record<number, string>);
export type OrchestIdentityFields = {
permissions: string[];
roles: string[];
@@ -13,15 +31,24 @@ export type OrchestIdentityFields = {
};
export function deriveOrchestIdentity(
permissions: string[] | undefined,
permissions: number[] | undefined,
): OrchestIdentityFields {
const perms = permissions ?? [];
const seqids = permissions ?? [];
const roles: string[] = [];
if (perms.includes(ADMIN_CLAIM)) {
if (seqids.includes(ADMIN_SEQID)) {
roles.push('super-admin');
}
const modules = Object.entries(MODULE_CLAIMS)
.filter(([claim]) => perms.includes(claim))
.map(([, key]) => key);
return { permissions: perms, roles, modules };
const modules = seqids
.filter((seqid) => seqid in MODULE_SEQID_TO_KEY)
.map((seqid) => MODULE_SEQID_TO_KEY[seqid]);
// Unknown seqids (not in the catalog) are dropped: auth-server ignores
// permissions[] in v1, so completeness of this translation isn't required.
const translatedPermissions = seqids
.filter((seqid) => seqid in SEQID_TO_CLAIM)
.map((seqid) => SEQID_TO_CLAIM[seqid]);
return { permissions: translatedPermissions, roles, modules };
}