diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 665729f..7807ab9 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -37,6 +37,9 @@ import { moduleGateSeqid, isModuleAllowed, isAdmin, + tenantOrchestApiHost, + isAllowedOrchestApiHost, + namespaceModule, } from './orchest-identity'; import { Authenticated, @@ -567,14 +570,54 @@ export class AuthController { return res.status(403).send(); } + const adminUser = isAdmin( + perms, + parseAdminSeqids(process.env.ORCHEST_ADMIN_PERMISSION_SEQIDS), + ); + 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)) - ) { + if (adminUser) { res.set('X-Auth-Roles', 'admin'); } + // Service-ingress caller: the orchest-api-authored annotation carries the + // scope. Authorize per-project against the tenant's orchest-api. A + // param-less request is the webserver-ingress case → identity only. + const permission = req.query?.permission as string | undefined; + const projectUuid = req.query?.project_uuid as string | undefined; + if (permission) { + // All-or-nothing: an incomplete annotation must not silently skip the + // per-project check. + if (!projectUuid) { + return res.status(403).send(); + } + const module = namespaceModule(process.env.ORCHEST_NAMESPACE_MODULE); + const host = tenantOrchestApiHost( + String(payload.customer_name ?? ''), + module, + ); + if (!isAllowedOrchestApiHost(host)) { + return res.status(403).send(); + } + const identityHeaders: Record = { + 'X-Auth-User': String(payload.user_id), + 'X-Auth-Username': String(payload.username ?? ''), + }; + if (adminUser) { + identityHeaders['X-Auth-Roles'] = 'admin'; + } + const decision = await this.authClient.authorizeOrchestServiceAccess({ + host, + permission, + projectUuid, + headers: identityHeaders, + }); + if (decision === 'deny') return res.status(403).send(); + if (decision === 'error') return res.status(502).send(); + // 'allow' falls through to the 200 (identity headers already set). + } + return res.status(200).send(); } } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8ca6844..37f3f61 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -9,6 +9,7 @@ import { import { ClientGrpc } from '@nestjs/microservices'; import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; import { lastValueFrom } from 'rxjs'; +import axios from 'axios'; import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc'; import { @@ -497,4 +498,33 @@ export class AuthClientService implements OnModuleInit { return; } + + /** + * Ask a tenant's orchest-api whether the acting user holds `permission` on + * `projectUuid` (the per-service authz half of /auth/module-identity). + * Fail-closed: any error / unexpected status → 'error' (the route denies). + */ + public async authorizeOrchestServiceAccess(args: { + host: string; + permission: string; + projectUuid?: string; + headers: Record; + }): Promise<'allow' | 'deny' | 'error'> { + const params: Record = { permission: args.permission }; + if (args.projectUuid) params.project_uuid = args.projectUuid; + try { + const resp = await axios.get(`http://${args.host}/api/authz/check`, { + params, + headers: args.headers, + timeout: 5000, + // Never throw on 4xx/5xx; branch on the status ourselves. + validateStatus: () => true, + }); + if (resp.status === 200) return 'allow'; + if (resp.status === 403) return 'deny'; + return 'error'; + } catch (e) { + return 'error'; + } + } } diff --git a/src/modules/auth/module-identity.controller.spec.ts b/src/modules/auth/module-identity.controller.spec.ts index 7f1830a..60a9fc9 100644 --- a/src/modules/auth/module-identity.controller.spec.ts +++ b/src/modules/auth/module-identity.controller.spec.ts @@ -125,3 +125,82 @@ describe('GET /auth/module-identity — identity', () => { expect(r._headers['X-Auth-Roles']).toBe('admin'); }); }); + +describe('GET /auth/module-identity — per-service authz', () => { + let controller: AuthController; + const auth = { + verifyAccessToken: jest.fn(), + authorizeOrchestServiceAccess: jest.fn(), + } as unknown as AuthClientService; + + const q = { permission: 'session.open', project_uuid: 'p-1' }; + + beforeEach(async () => { + jest.resetAllMocks(); + process.env.ORCHEST_MODULE_PERMISSION_SEQID = '31'; + process.env.ORCHEST_ADMIN_PERMISSION_SEQIDS = '34'; + process.env.ORCHEST_NAMESPACE_MODULE = 'intelli'; + controller = await makeController(auth); + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-1', + username: 'alice', + permissions: [31], + customer_name: 'acme', + }); + }); + + it('has grant → 200 and calls the tenant host', async () => { + (auth.authorizeOrchestServiceAccess as jest.Mock).mockResolvedValue('allow'); + const r = res(); + await controller.moduleIdentity(req('tok', q), r); + expect(r._status).toBe(200); + expect(auth.authorizeOrchestServiceAccess).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'orchest-api.orchest-intelli-acme.svc.cluster.local', + permission: 'session.open', + projectUuid: 'p-1', + }), + ); + }); + + it('lacks grant → 403', async () => { + (auth.authorizeOrchestServiceAccess as jest.Mock).mockResolvedValue('deny'); + const r = res(); + await controller.moduleIdentity(req('tok', q), r); + expect(r._status).toBe(403); + }); + + it('orchest-api error → 502 (fail-closed)', async () => { + (auth.authorizeOrchestServiceAccess as jest.Mock).mockResolvedValue('error'); + const r = res(); + await controller.moduleIdentity(req('tok', q), r); + expect(r._status).toBe(502); + }); + + it('permission without project_uuid → 403 (all-or-nothing)', async () => { + const r = res(); + await controller.moduleIdentity(req('tok', { permission: 'session.open' }), r); + expect(r._status).toBe(403); + expect(auth.authorizeOrchestServiceAccess).not.toHaveBeenCalled(); + }); + + it('no authz params → 200 identity-only (webserver case)', async () => { + const r = res(); + await controller.moduleIdentity(req('tok', {}), r); + expect(r._status).toBe(200); + expect(auth.authorizeOrchestServiceAccess).not.toHaveBeenCalled(); + }); + + it('malformed customer_name → host fails allowlist → 403, no call', async () => { + (auth.verifyAccessToken as jest.Mock).mockResolvedValue({ + user_id: 'u-1', + username: 'alice', + permissions: [31], + customer_name: '', + }); + const r = res(); + await controller.moduleIdentity(req('tok', q), r); + expect(r._status).toBe(403); + expect(auth.authorizeOrchestServiceAccess).not.toHaveBeenCalled(); + }); +});