128 lines
5.0 KiB
TypeScript
128 lines
5.0 KiB
TypeScript
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
import type { Pool } from 'pg';
|
|
|
|
export interface DataPermission {
|
|
activity: string;
|
|
view: string;
|
|
}
|
|
|
|
export type ProxyDataRouteFn = (
|
|
app: import('fastify').FastifyInstance,
|
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
publicPath: string,
|
|
upstreamResource: string,
|
|
permission: DataPermission | null,
|
|
) => void;
|
|
|
|
type RegistryEntry = { upstreamResource: string; publicPath: string; permission: DataPermission | null };
|
|
|
|
export interface ProxyRegistry {
|
|
proxy: (
|
|
app: import('fastify').FastifyInstance,
|
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
publicPath: string,
|
|
upstreamResource: string,
|
|
permission: DataPermission | null,
|
|
) => void;
|
|
allDataPermissions: () => DataPermission[];
|
|
getPermissionByUpstream: (resource: string, method: string) => DataPermission | null | undefined;
|
|
getPublicPath: (resource: string, method: string) => string | undefined;
|
|
}
|
|
|
|
export function createProxyRegistry(db: Pool, postgrestUrl: string): ProxyRegistry {
|
|
const registry = new Map<string, RegistryEntry>();
|
|
|
|
function proxy(
|
|
app: import('fastify').FastifyInstance,
|
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
|
publicPath: string,
|
|
upstreamResource: string,
|
|
permission: DataPermission | null,
|
|
): void {
|
|
registry.set(`${method}:${upstreamResource}`, { upstreamResource, publicPath, permission });
|
|
app.route({
|
|
method,
|
|
url: publicPath,
|
|
schema: { hide: true } as Record<string, unknown>,
|
|
handler: makeHandler(upstreamResource, permission),
|
|
});
|
|
}
|
|
|
|
function makeHandler(upstreamResource: string, permission: DataPermission | null) {
|
|
return async function handler(req: FastifyRequest, reply: FastifyReply) {
|
|
const user = ((req as unknown) as Record<string, unknown>).user as { id: string } | null;
|
|
if (!user) return reply.code(401).send({ error: 'Authentication required' });
|
|
|
|
if (permission !== null) {
|
|
const orgId = req.headers['x-org-id'];
|
|
const orgIdVal = Array.isArray(orgId) ? orgId[0] : orgId;
|
|
if (!orgIdVal) return reply.code(400).send({ error: 'X-Org-Id header is required' });
|
|
|
|
const { rows } = await db.query<{ allowed: boolean }>(
|
|
'SELECT morbac.is_allowed($1::UUID, $2::UUID, $3, $4) AS allowed',
|
|
[user.id, orgIdVal, permission.activity, permission.view],
|
|
);
|
|
if (!rows[0]?.allowed) return reply.code(403).send({ error: 'Forbidden' });
|
|
}
|
|
|
|
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
const url = `${postgrestUrl}/${upstreamResource}${qs}`;
|
|
|
|
const headers: Record<string, string> = {};
|
|
const pick = (src: string, dst: string = src) => {
|
|
const v = req.headers[src.toLowerCase()];
|
|
if (v) headers[dst] = Array.isArray(v) ? v[0] : v;
|
|
};
|
|
pick('authorization', 'Authorization');
|
|
pick('x-org-id', 'X-Org-Id');
|
|
pick('x-org-ids', 'X-Org-Ids');
|
|
pick('content-type', 'Content-Type');
|
|
pick('prefer', 'Prefer');
|
|
pick('accept', 'Accept');
|
|
|
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.body != null;
|
|
const body = hasBody ? JSON.stringify(req.body) : undefined;
|
|
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await fetch(url, { method: req.method, headers, body });
|
|
} catch {
|
|
return reply.code(503).send({ error: 'Data service unavailable' });
|
|
}
|
|
|
|
reply.code(upstream.status);
|
|
const fwd = (name: string) => {
|
|
const v = upstream.headers.get(name);
|
|
if (v !== null) reply.header(name, v);
|
|
};
|
|
fwd('content-type');
|
|
fwd('content-range');
|
|
fwd('x-total-count');
|
|
|
|
return reply.send(await upstream.text());
|
|
};
|
|
}
|
|
|
|
function allDataPermissions(): DataPermission[] {
|
|
const seen = new Set<string>();
|
|
const result: DataPermission[] = [];
|
|
for (const { permission } of registry.values()) {
|
|
if (permission != null) {
|
|
const key = `${permission.activity}:${permission.view}`;
|
|
if (!seen.has(key)) { seen.add(key); result.push(permission); }
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getPermissionByUpstream(resource: string, method: string): DataPermission | null | undefined {
|
|
return registry.get(`${method.toUpperCase()}:${resource}`)?.permission;
|
|
}
|
|
|
|
function getPublicPath(resource: string, method: string): string | undefined {
|
|
return registry.get(`${method.toUpperCase()}:${resource}`)?.publicPath;
|
|
}
|
|
|
|
return { proxy, allDataPermissions, getPermissionByUpstream, getPublicPath };
|
|
}
|