mirror of
https://github.com/dadosfera/maestro.git
synced 2026-08-31 19:58:21 +00:00
feat(auth): per-service authz branch on /auth/module-identity
When the ingress annotation carries permission (+project_uuid), authorize against the tenant's orchest-api /api/authz/check (host derived from the JWT, allowlist-guarded); relay allow/deny, fail-closed 502. All-or-nothing on incomplete params; param-less requests stay identity-only (webserver case). Co-Authored-By: WOZCODE <contact@withwoz.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
'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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
}): Promise<'allow' | 'deny' | 'error'> {
|
||||
const params: Record<string, string> = { 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user