From 9c5748503137f209ac7c78d5771797721fc76ba4 Mon Sep 17 00:00:00 2001 From: Rafael Date: Mon, 24 Aug 2026 15:39:40 -0300 Subject: [PATCH 1/5] feat(auth): pure helper deriving Orchest identity from permissions --- src/modules/auth/orchest-identity.spec.ts | 36 +++++++++++++++++++++++ src/modules/auth/orchest-identity.ts | 27 +++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/modules/auth/orchest-identity.spec.ts create mode 100644 src/modules/auth/orchest-identity.ts diff --git a/src/modules/auth/orchest-identity.spec.ts b/src/modules/auth/orchest-identity.spec.ts new file mode 100644 index 0000000..c25ceaf --- /dev/null +++ b/src/modules/auth/orchest-identity.spec.ts @@ -0,0 +1,36 @@ +import { deriveOrchestIdentity } from './orchest-identity'; + +describe('deriveOrchestIdentity', () => { + it('passes permissions through verbatim', () => { + expect(deriveOrchestIdentity(['intelligence:open']).permissions).toEqual([ + 'intelligence:open', + ]); + }); + + it('marks super-admin from users:admin', () => { + expect(deriveOrchestIdentity(['users:admin']).roles).toContain('super-admin'); + }); + + it('omits super-admin when users:admin absent', () => { + expect(deriveOrchestIdentity(['intelligence:open']).roles).not.toContain( + 'super-admin', + ); + }); + + it('maps intelligence:open and process:open to module keys', () => { + const { modules } = deriveOrchestIdentity(['intelligence:open', 'process:open']); + expect(modules.sort()).toEqual(['intelligence', 'process']); + }); + + it('ignores non-module permissions in modules', () => { + expect(deriveOrchestIdentity(['users:admin']).modules).toEqual([]); + }); + + it('handles undefined/empty permissions', () => { + expect(deriveOrchestIdentity(undefined)).toEqual({ + permissions: [], + roles: [], + modules: [], + }); + }); +}); diff --git a/src/modules/auth/orchest-identity.ts b/src/modules/auth/orchest-identity.ts new file mode 100644 index 0000000..8060e34 --- /dev/null +++ b/src/modules/auth/orchest-identity.ts @@ -0,0 +1,27 @@ +// 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 = { + 'intelligence:open': 'intelligence', + 'process:open': 'process', +}; + +export type OrchestIdentityFields = { + permissions: string[]; + roles: string[]; + modules: string[]; +}; + +export function deriveOrchestIdentity( + permissions: string[] | undefined, +): OrchestIdentityFields { + const perms = permissions ?? []; + const roles: string[] = []; + if (perms.includes(ADMIN_CLAIM)) { + roles.push('super-admin'); + } + const modules = Object.entries(MODULE_CLAIMS) + .filter(([claim]) => perms.includes(claim)) + .map(([, key]) => key); + return { permissions: perms, roles, modules }; +} From a16fefe691daf7cf5583a575e6d17fa795a42c69 Mon Sep 17 00:00:00 2001 From: Rafael Date: Mon, 24 Aug 2026 15:45:09 -0300 Subject: [PATCH 2/5] feat(auth): return permissions/roles/modules from /auth/me (all branches) --- src/modules/auth/auth.controller.ts | 5 +++- src/modules/auth/auth.service.ts | 2 ++ src/modules/auth/dtos/login.ts | 5 +++- .../auth/orchest-identity.integration.spec.ts | 27 +++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/modules/auth/orchest-identity.integration.spec.ts diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 40eaad9..e8725ea 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -494,7 +494,10 @@ export class AuthController { id: api_key.customer_id, name: api_key.customer_name, tier: api_key.customer_tier, - } + }, + permissions: [], + roles: [], + modules: [], }; return res.status(200).json(userDto); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8539d0e..e0f6ba8 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -35,6 +35,7 @@ import { BulkEditResponse, UserDTO } from './dtos/login'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { Request, Response } from 'express'; +import { deriveOrchestIdentity } from './orchest-identity'; type AuthSession = { accessToken?: string; @@ -447,6 +448,7 @@ export class AuthClientService implements OnModuleInit { name: payload.customer_name, tier: payload.customer_tier, }, + ...deriveOrchestIdentity(payload.permissions), }; return userDto; diff --git a/src/modules/auth/dtos/login.ts b/src/modules/auth/dtos/login.ts index 2e22363..8532cb1 100644 --- a/src/modules/auth/dtos/login.ts +++ b/src/modules/auth/dtos/login.ts @@ -152,5 +152,8 @@ export type UserDTO = { id: string, name: string, tier: string, - } + }, + permissions: string[], + roles: string[], + modules: string[], } diff --git a/src/modules/auth/orchest-identity.integration.spec.ts b/src/modules/auth/orchest-identity.integration.spec.ts new file mode 100644 index 0000000..bfe5a29 --- /dev/null +++ b/src/modules/auth/orchest-identity.integration.spec.ts @@ -0,0 +1,27 @@ +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. +describe('/auth/me field contract', () => { + it('cookie/refresh path derives from payload permissions', () => { + const enriched = { + id: 'u', name: 'n', email: 'e', + customer: { id: 'c', name: 'cust', tier: 't' }, + ...deriveOrchestIdentity(['users:admin', 'intelligence:open']), + }; + expect(enriched.roles).toContain('super-admin'); + expect(enriched.modules).toContain('intelligence'); + expect(enriched.permissions).toHaveLength(2); + }); + + it('api-key branch is empty for all three fields', () => { + const apiKeyDto = { + id: 'u', name: 'n', email: 'n', + customer: { id: 'c', name: 'cust', tier: 't' }, + permissions: [] as string[], roles: [] as string[], modules: [] as string[], + }; + expect(apiKeyDto.permissions).toEqual([]); + expect(apiKeyDto.roles).toEqual([]); + expect(apiKeyDto.modules).toEqual([]); + }); +}); From a5a685ee3fd5ddaf34075c9da7c5b25327d4f1d8 Mon Sep 17 00:00:00 2001 From: Rafael Date: Mon, 24 Aug 2026 16:03:20 -0300 Subject: [PATCH 3/5] 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 --- src/modules/auth/auth.controller.ts | 3 +- .../auth/orchest-identity.integration.spec.ts | 10 ++-- src/modules/auth/orchest-identity.spec.ts | 32 ++++++----- src/modules/auth/orchest-identity.ts | 53 ++++++++++++++----- 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e8725ea..e88cc0f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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, diff --git a/src/modules/auth/orchest-identity.integration.spec.ts b/src/modules/auth/orchest-identity.integration.spec.ts index bfe5a29..ab694ef 100644 --- a/src/modules/auth/orchest-identity.integration.spec.ts +++ b/src/modules/auth/orchest-identity.integration.spec.ts @@ -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', () => { diff --git a/src/modules/auth/orchest-identity.spec.ts b/src/modules/auth/orchest-identity.spec.ts index c25ceaf..dbaa3fe 100644 --- a/src/modules/auth/orchest-identity.spec.ts +++ b/src/modules/auth/orchest-identity.spec.ts @@ -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', () => { diff --git a/src/modules/auth/orchest-identity.ts b/src/modules/auth/orchest-identity.ts index 8060e34..38d90eb 100644 --- a/src/modules/auth/orchest-identity.ts +++ b/src/modules/auth/orchest-identity.ts @@ -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 = { - '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 = { + [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 = Object.values( + PERMISSIONS_GROUPS, +).reduce((catalog, group) => { + for (const permission of Object.values(group.permissions)) { + catalog[permission.seqid] = permission.claim; + } + return catalog; +}, {} as Record); + 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 }; } From cd21fd0b7bbaed25114b4759c71d6b73ceae99c1 Mon Sep 17 00:00:00 2001 From: Rafael Date: Mon, 24 Aug 2026 16:30:33 -0300 Subject: [PATCH 4/5] refactor(auth): /auth/me returns permissions only (drop roles/modules) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Maestro a pure identity provider: /auth/me exposes the user's permission claim strings and nothing consumer-specific. Consumers derive whatever meaning they need (roles, module access, groups) from the claim vocabulary — claims are already namespaced group:action. - UserDTO: drop roles[]/modules[], keep permissions[]. - Helper shrinks to a generic seqid->claim translation (orchest-identity.ts -> permission-claims.ts, translateSeqidsToClaims). - api-key branch: permissions: [] only. The roles/modules derivation moves entirely to the consumer (Orchest's auth-server adapter). Co-Authored-By: WOZCODE --- src/modules/auth/auth.controller.ts | 2 - src/modules/auth/auth.service.ts | 4 +- src/modules/auth/dtos/login.ts | 2 - .../auth/orchest-identity.integration.spec.ts | 29 ---------- src/modules/auth/orchest-identity.spec.ts | 44 --------------- src/modules/auth/orchest-identity.ts | 54 ------------------- src/modules/auth/permission-claims.spec.ts | 30 +++++++++++ src/modules/auth/permission-claims.ts | 29 ++++++++++ 8 files changed, 61 insertions(+), 133 deletions(-) delete mode 100644 src/modules/auth/orchest-identity.integration.spec.ts delete mode 100644 src/modules/auth/orchest-identity.spec.ts delete mode 100644 src/modules/auth/orchest-identity.ts create mode 100644 src/modules/auth/permission-claims.spec.ts create mode 100644 src/modules/auth/permission-claims.ts diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e88cc0f..5eb2b0f 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -497,8 +497,6 @@ export class AuthController { tier: api_key.customer_tier, }, permissions: [], - roles: [], - modules: [], }; return res.status(200).json(userDto); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index e0f6ba8..a7d9868 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -35,7 +35,7 @@ import { BulkEditResponse, UserDTO } from './dtos/login'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { Request, Response } from 'express'; -import { deriveOrchestIdentity } from './orchest-identity'; +import { translateSeqidsToClaims } from './permission-claims'; type AuthSession = { accessToken?: string; @@ -448,7 +448,7 @@ export class AuthClientService implements OnModuleInit { name: payload.customer_name, tier: payload.customer_tier, }, - ...deriveOrchestIdentity(payload.permissions), + permissions: translateSeqidsToClaims(payload.permissions), }; return userDto; diff --git a/src/modules/auth/dtos/login.ts b/src/modules/auth/dtos/login.ts index 8532cb1..3317904 100644 --- a/src/modules/auth/dtos/login.ts +++ b/src/modules/auth/dtos/login.ts @@ -154,6 +154,4 @@ export type UserDTO = { tier: string, }, permissions: string[], - roles: string[], - modules: string[], } diff --git a/src/modules/auth/orchest-identity.integration.spec.ts b/src/modules/auth/orchest-identity.integration.spec.ts deleted file mode 100644 index ab694ef..0000000 --- a/src/modules/auth/orchest-identity.integration.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -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. 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 (numeric seqids)', () => { - const enriched = { - id: 'u', name: 'n', email: 'e', - customer: { id: 'c', name: 'cust', tier: 't' }, - ...deriveOrchestIdentity([34, 31]), - }; - expect(enriched.roles).toContain('super-admin'); - expect(enriched.modules).toContain('intelligence'); - expect(enriched.permissions).toEqual(['users:admin', 'intelligence:open']); - }); - - it('api-key branch is empty for all three fields', () => { - const apiKeyDto = { - id: 'u', name: 'n', email: 'n', - customer: { id: 'c', name: 'cust', tier: 't' }, - permissions: [] as string[], roles: [] as string[], modules: [] as string[], - }; - expect(apiKeyDto.permissions).toEqual([]); - expect(apiKeyDto.roles).toEqual([]); - expect(apiKeyDto.modules).toEqual([]); - }); -}); diff --git a/src/modules/auth/orchest-identity.spec.ts b/src/modules/auth/orchest-identity.spec.ts deleted file mode 100644 index dbaa3fe..0000000 --- a/src/modules/auth/orchest-identity.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -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('translates seqids to claim strings in permissions', () => { - expect(deriveOrchestIdentity([31]).permissions).toEqual([ - 'intelligence:open', - ]); - }); - - it('marks super-admin from the users:admin seqid (34)', () => { - expect(deriveOrchestIdentity([34]).roles).toContain('super-admin'); - }); - - it('omits super-admin when the admin seqid is absent', () => { - expect(deriveOrchestIdentity([31]).roles).not.toContain('super-admin'); - }); - - it('maps intelligence and process seqids to module keys', () => { - const { modules } = deriveOrchestIdentity([31, 43]); - expect(modules.sort()).toEqual(['intelligence', 'process']); - }); - - 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', () => { - expect(deriveOrchestIdentity(undefined)).toEqual({ - permissions: [], - roles: [], - modules: [], - }); - }); -}); diff --git a/src/modules/auth/orchest-identity.ts b/src/modules/auth/orchest-identity.ts deleted file mode 100644 index 38d90eb..0000000 --- a/src/modules/auth/orchest-identity.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 = { - [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 = Object.values( - PERMISSIONS_GROUPS, -).reduce((catalog, group) => { - for (const permission of Object.values(group.permissions)) { - catalog[permission.seqid] = permission.claim; - } - return catalog; -}, {} as Record); - -export type OrchestIdentityFields = { - permissions: string[]; - roles: string[]; - modules: string[]; -}; - -export function deriveOrchestIdentity( - permissions: number[] | undefined, -): OrchestIdentityFields { - const seqids = permissions ?? []; - - const roles: string[] = []; - if (seqids.includes(ADMIN_SEQID)) { - roles.push('super-admin'); - } - - 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 }; -} diff --git a/src/modules/auth/permission-claims.spec.ts b/src/modules/auth/permission-claims.spec.ts new file mode 100644 index 0000000..c528c5b --- /dev/null +++ b/src/modules/auth/permission-claims.spec.ts @@ -0,0 +1,30 @@ +import { translateSeqidsToClaims } from './permission-claims'; + +// The JWT permissions claim carries numeric seqids at runtime +// (34 = users:admin, 31 = intelligence:open, 43 = process:open), so +// fixtures here use numeric seqid arrays. +describe('translateSeqidsToClaims', () => { + it('translates a single seqid to its claim string', () => { + expect(translateSeqidsToClaims([31])).toEqual(['intelligence:open']); + }); + + it('translates multiple seqids, preserving order', () => { + expect(translateSeqidsToClaims([34, 31, 43])).toEqual([ + 'users:admin', + 'intelligence:open', + 'process:open', + ]); + }); + + it('drops unknown seqids not present in the catalog', () => { + expect(translateSeqidsToClaims([34, 999999])).toEqual(['users:admin']); + }); + + it('returns an empty array for undefined permissions', () => { + expect(translateSeqidsToClaims(undefined)).toEqual([]); + }); + + it('returns an empty array for empty permissions', () => { + expect(translateSeqidsToClaims([])).toEqual([]); + }); +}); diff --git a/src/modules/auth/permission-claims.ts b/src/modules/auth/permission-claims.ts new file mode 100644 index 0000000..470631e --- /dev/null +++ b/src/modules/auth/permission-claims.ts @@ -0,0 +1,29 @@ +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. This module translates those +// seqids into their human-readable claim strings so /auth/me can expose a +// self-describing `permissions` array; consumers derive whatever meaning +// they need (roles, module access, groups) from the claim vocabulary. +// +// Full seqid -> claim catalog, built once from every group/permission in +// PERMISSIONS_GROUPS. Generic: no consumer-specific knowledge lives here. +const SEQID_TO_CLAIM: Record = Object.values( + PERMISSIONS_GROUPS, +).reduce((catalog, group) => { + for (const permission of Object.values(group.permissions)) { + catalog[permission.seqid] = permission.claim; + } + return catalog; +}, {} as Record); + +// Translate the JWT's numeric permission seqids into claim strings. +// Unknown seqids (not present in the catalog) are dropped. +export function translateSeqidsToClaims( + permissions: number[] | undefined, +): string[] { + return (permissions ?? []) + .filter((seqid) => seqid in SEQID_TO_CLAIM) + .map((seqid) => SEQID_TO_CLAIM[seqid]); +} From cac36f2c604867d66b95cc74ad21e845434c623b Mon Sep 17 00:00:00 2001 From: Rafael Date: Mon, 24 Aug 2026 16:56:23 -0300 Subject: [PATCH 5/5] refactor(auth): /auth/me returns raw permission seqids Return payload.permissions verbatim (numeric seqids) instead of translating them to claim strings. Consumers own the seqid->meaning mapping. Drops permission-claims.ts entirely; UserDTO.permissions is now number[]. Co-Authored-By: WOZCODE --- src/modules/auth/auth.service.ts | 5 ++-- src/modules/auth/dtos/login.ts | 2 +- src/modules/auth/permission-claims.spec.ts | 30 ---------------------- src/modules/auth/permission-claims.ts | 29 --------------------- 4 files changed, 4 insertions(+), 62 deletions(-) delete mode 100644 src/modules/auth/permission-claims.spec.ts delete mode 100644 src/modules/auth/permission-claims.ts diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index a7d9868..c82aeef 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -35,7 +35,6 @@ import { BulkEditResponse, UserDTO } from './dtos/login'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { Request, Response } from 'express'; -import { translateSeqidsToClaims } from './permission-claims'; type AuthSession = { accessToken?: string; @@ -448,7 +447,9 @@ export class AuthClientService implements OnModuleInit { name: payload.customer_name, tier: payload.customer_tier, }, - permissions: translateSeqidsToClaims(payload.permissions), + // Raw permission seqids from the JWT. Consumers own the seqid->meaning + // mapping (e.g. Orchest's auth-server); Maestro reports them as-is. + permissions: payload.permissions ?? [], }; return userDto; diff --git a/src/modules/auth/dtos/login.ts b/src/modules/auth/dtos/login.ts index 3317904..1ea22f0 100644 --- a/src/modules/auth/dtos/login.ts +++ b/src/modules/auth/dtos/login.ts @@ -153,5 +153,5 @@ export type UserDTO = { name: string, tier: string, }, - permissions: string[], + permissions: number[], } diff --git a/src/modules/auth/permission-claims.spec.ts b/src/modules/auth/permission-claims.spec.ts deleted file mode 100644 index c528c5b..0000000 --- a/src/modules/auth/permission-claims.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { translateSeqidsToClaims } from './permission-claims'; - -// The JWT permissions claim carries numeric seqids at runtime -// (34 = users:admin, 31 = intelligence:open, 43 = process:open), so -// fixtures here use numeric seqid arrays. -describe('translateSeqidsToClaims', () => { - it('translates a single seqid to its claim string', () => { - expect(translateSeqidsToClaims([31])).toEqual(['intelligence:open']); - }); - - it('translates multiple seqids, preserving order', () => { - expect(translateSeqidsToClaims([34, 31, 43])).toEqual([ - 'users:admin', - 'intelligence:open', - 'process:open', - ]); - }); - - it('drops unknown seqids not present in the catalog', () => { - expect(translateSeqidsToClaims([34, 999999])).toEqual(['users:admin']); - }); - - it('returns an empty array for undefined permissions', () => { - expect(translateSeqidsToClaims(undefined)).toEqual([]); - }); - - it('returns an empty array for empty permissions', () => { - expect(translateSeqidsToClaims([])).toEqual([]); - }); -}); diff --git a/src/modules/auth/permission-claims.ts b/src/modules/auth/permission-claims.ts deleted file mode 100644 index 470631e..0000000 --- a/src/modules/auth/permission-claims.ts +++ /dev/null @@ -1,29 +0,0 @@ -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. This module translates those -// seqids into their human-readable claim strings so /auth/me can expose a -// self-describing `permissions` array; consumers derive whatever meaning -// they need (roles, module access, groups) from the claim vocabulary. -// -// Full seqid -> claim catalog, built once from every group/permission in -// PERMISSIONS_GROUPS. Generic: no consumer-specific knowledge lives here. -const SEQID_TO_CLAIM: Record = Object.values( - PERMISSIONS_GROUPS, -).reduce((catalog, group) => { - for (const permission of Object.values(group.permissions)) { - catalog[permission.seqid] = permission.claim; - } - return catalog; -}, {} as Record); - -// Translate the JWT's numeric permission seqids into claim strings. -// Unknown seqids (not present in the catalog) are dropped. -export function translateSeqidsToClaims( - permissions: number[] | undefined, -): string[] { - return (permissions ?? []) - .filter((seqid) => seqid in SEQID_TO_CLAIM) - .map((seqid) => SEQID_TO_CLAIM[seqid]); -}