refactor(auth): /auth/me returns permissions only (drop roles/modules)

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 <contact@withwoz.com>
This commit is contained in:
Rafael
2026-08-24 16:30:33 -03:00
co-authored by WOZCODE
parent a5a685ee3f
commit cd21fd0b7b
8 changed files with 61 additions and 133 deletions
-2
View File
@@ -497,8 +497,6 @@ export class AuthController {
tier: api_key.customer_tier,
},
permissions: [],
roles: [],
modules: [],
};
return res.status(200).json(userDto);
+2 -2
View File
@@ -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;
-2
View File
@@ -154,6 +154,4 @@ export type UserDTO = {
tier: string,
},
permissions: string[],
roles: string[],
modules: string[],
}
@@ -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([]);
});
});
-44
View File
@@ -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: [],
});
});
});
-54
View File
@@ -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<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[];
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 };
}
@@ -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([]);
});
});
+29
View File
@@ -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<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>);
// 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]);
}