feat: add tenant and agent Langfuse configuration

This commit is contained in:
Danny Avila 2026-05-11 21:20:53 -04:00
parent 3e7262cfe0
commit 03e21214d0
21 changed files with 687 additions and 10 deletions

View file

@ -60,6 +60,70 @@ const getSafeModelParameters = (modelParameters) => {
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
};
const toPlainObject = (value) =>
value && typeof value.toObject === 'function' ? value.toObject() : value;
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
const normalizeLangfuseConfig = (incoming, existing) => {
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
return incoming;
}
const existingConfig = toPlainObject(existing) ?? {};
const normalized = {};
if (typeof incoming.enabled === 'boolean') {
normalized.enabled = incoming.enabled;
}
for (const key of ['publicKey', 'baseUrl']) {
if (isNonEmptyString(incoming[key])) {
normalized[key] = incoming[key].trim();
}
}
if (isNonEmptyString(incoming.secretKey)) {
normalized.secretKey = incoming.secretKey.trim();
} else if (isNonEmptyString(existingConfig.secretKey)) {
normalized.secretKey = existingConfig.secretKey;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
const redactLangfuseSecret = (agent) => {
const payload = toPlainObject(agent);
if (!payload || typeof payload !== 'object') {
return payload;
}
const redactSingleAgent = (value) => {
if (!value || typeof value !== 'object') {
return value;
}
if (value.langfuse && typeof value.langfuse === 'object' && value.langfuse.secretKey) {
return {
...value,
langfuse: {
...toPlainObject(value.langfuse),
secretKey: '',
},
};
}
return value;
};
const redactedPayload = redactSingleAgent(payload);
if (Array.isArray(redactedPayload.versions)) {
redactedPayload.versions = redactedPayload.versions.map((version) =>
redactSingleAgent(toPlainObject(version)),
);
}
return redactedPayload;
};
/**
* Looks up each referenced agent id in Mongo, splits them into three
* buckets the caller needs for validation: ids that don't exist at all,
@ -284,6 +348,13 @@ const createAgentHandler = async (req, res) => {
agentData.model_parameters = removeNullishValues(agentData.model_parameters, true);
}
if (agentData.langfuse) {
agentData.langfuse = normalizeLangfuseConfig(agentData.langfuse);
if (!agentData.langfuse) {
delete agentData.langfuse;
}
}
const { id: userId, role: userRole } = req.user;
if (agentData.tool_resources) {
@ -389,7 +460,7 @@ const createAgentHandler = async (req, res) => {
);
}
res.status(201).json(agent);
res.status(201).json(redactLangfuseSecret(agent));
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents] Validation error', error.errors);
@ -471,8 +542,8 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
});
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
// EDIT permission: Full agent details, with write-only Langfuse secret redacted.
return res.status(200).json(redactLangfuseSecret(agent));
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
res.status(500).json({ error: error.message });
@ -556,6 +627,13 @@ const updateAgentHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}
if (updateData.langfuse) {
updateData.langfuse = normalizeLangfuseConfig(updateData.langfuse, existingAgent.langfuse);
if (!updateData.langfuse) {
delete updateData.langfuse;
}
}
// Convert legacy OCR tool resource to context format in existing agent
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
if (ocrConversion.tool_resources) {
@ -615,7 +693,7 @@ const updateAgentHandler = async (req, res) => {
delete updatedAgent.author;
}
return res.json(updatedAgent);
return res.json(redactLangfuseSecret(updatedAgent));
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents/:id] Validation error', error.errors);
@ -794,7 +872,7 @@ const duplicateAgentHandler = async (req, res) => {
}
return res.status(201).json({
agent: newAgent,
agent: redactLangfuseSecret(newAgent),
actions: newActionsList,
});
} catch (error) {
@ -948,7 +1026,7 @@ const getListAgentsHandler = async (req, res) => {
// Silently ignore mapping errors
void e;
}
return agent;
return redactLangfuseSecret(agent);
});
return res.json(data);

View file

@ -178,6 +178,39 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
});
test('should persist Langfuse config but redact secret key in create response', async () => {
mockReq.body = {
name: 'Langfuse Agent',
provider: 'openai',
model: 'gpt-4',
langfuse: {
enabled: true,
publicKey: ' pk-test ',
secretKey: ' sk-test ',
baseUrl: ' https://cloud.langfuse.com ',
},
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.langfuse).toEqual({
enabled: true,
publicKey: 'pk-test',
secretKey: '',
baseUrl: 'https://cloud.langfuse.com',
});
const agentInDb = await Agent.findOne({ id: createdAgent.id }).lean();
expect(agentInDb.langfuse).toEqual({
enabled: true,
publicKey: 'pk-test',
secretKey: 'sk-test',
baseUrl: 'https://cloud.langfuse.com',
});
});
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
const maliciousData = {
// Required fields
@ -751,6 +784,82 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.model_parameters.maxContextTokens).toBeUndefined();
});
test('should preserve existing Langfuse secret when update sends an empty secret', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: 'sk-original',
baseUrl: 'https://cloud.langfuse.com',
},
},
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
langfuse: {
enabled: true,
publicKey: ' pk-updated ',
secretKey: '',
baseUrl: ' https://us.cloud.langfuse.com ',
},
};
await updateAgentHandler(mockReq, mockRes);
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.langfuse).toEqual({
enabled: true,
publicKey: 'pk-updated',
secretKey: '',
baseUrl: 'https://us.cloud.langfuse.com',
});
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
expect(agentInDb.langfuse).toEqual({
enabled: true,
publicKey: 'pk-updated',
secretKey: 'sk-original',
baseUrl: 'https://us.cloud.langfuse.com',
});
});
test('should update Langfuse secret explicitly while redacting it in update response', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: 'sk-original',
baseUrl: 'https://cloud.langfuse.com',
},
},
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
langfuse: {
enabled: true,
publicKey: 'pk-original',
secretKey: ' sk-updated ',
baseUrl: 'https://cloud.langfuse.com',
},
};
await updateAgentHandler(mockReq, mockRes);
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.langfuse.secretKey).toBe('');
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
expect(agentInDb.langfuse.secretKey).toBe('sk-updated');
});
test('should return 404 for non-existent agent', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = `agent_${uuidv4()}`; // Non-existent ID
@ -1303,6 +1412,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].name).toBe('Agent A1');
});
test('should not expose Langfuse secrets in agent list responses', async () => {
await Agent.updateOne(
{ id: agentA1.id },
{
langfuse: {
enabled: true,
publicKey: 'pk-list',
secretKey: 'sk-list',
baseUrl: 'https://cloud.langfuse.com',
},
},
);
mockReq.user.id = userB.toString();
findAccessibleResources.mockResolvedValue([agentA1._id]);
findPubliclyAccessibleResources.mockResolvedValue([]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data).toHaveLength(1);
expect(response.data[0].langfuse).toBeUndefined();
});
test('should return multiple accessible agents', async () => {
// User B has access to multiple agents
mockReq.user.id = userB.toString();