Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions forge/auditLog/formatters.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const formatLogEntry = (auditLogDbRow) => {
User: auditLogDbRow.User, // TODO: Kept for compatibility. Remove once Audit Log UI overhaul complete
event: auditLogDbRow.event,
createdAt: auditLogDbRow.createdAt,
source: auditLogDbRow.source || null,
scope: {
id: auditLogDbRow.entityId,
type: auditLogDbRow.entityType
Expand Down Expand Up @@ -201,6 +202,12 @@ const formatLogEntry = (auditLogDbRow) => {
formatted.body.context = { key: body.key, scope: body.scope, store: body.store }
}

// pass through PAT audit context if present
if (body?.sourceContext) {
formatted.body = formatted.body || {}
formatted.body.sourceContext = body.sourceContext
}

// format log entries for know Node-RED audit events
if (formatted.event === 'flows.set') {
formatted.body = formatted.body || {}
Expand Down
60 changes: 49 additions & 11 deletions forge/db/controllers/AuditLog.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { requestContext } = require('@fastify/request-context')

function encodeBody (body) {
if (body !== undefined) {
const result = JSON.stringify(body)
Expand All @@ -7,6 +9,30 @@ function encodeBody (body) {
}
}

function getSourceContext () {
const ctx = requestContext.get('sourceContext')
if (!ctx) {
return { source: null, sourceContext: undefined }
}
const sc = {}
if (ctx.tokenId != null) { sc.tokenId = ctx.tokenId }
if (ctx.toolName) { sc.toolName = ctx.toolName }
if (ctx.correlationId) { sc.correlationId = ctx.correlationId }
return {
source: ctx.source ?? null,
sourceContext: Object.keys(sc).length > 0 ? sc : undefined
}
}

function applySourceContext (body) {
const { source, sourceContext } = getSourceContext()
if (sourceContext) {
body = body || {}
body.sourceContext = sourceContext
}
return { source, body }
}

function addToLog (app, entityType, entityId, event, body) {
const details = [`event: ${event}`]
if (entityType) { details.push(`type: ${entityType}`) }
Expand All @@ -26,62 +52,74 @@ function addToLog (app, entityType, entityId, event, body) {

module.exports = {
platformLog: async function (app, UserId, event, body) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'platform',
entityId: null,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
addToLog(app, 'platform', null, event, body)
addToLog(app, 'platform', null, event, enrichedBody)
},
userLog: async function (app, UserId, event, body, entityId) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'user',
entityId: entityId || null,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
addToLog(app, 'user', entityId, event, body)
addToLog(app, 'user', entityId, event, enrichedBody)
},
applicationLog: async function (app, ApplicationId, UserId, event, body) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'application',
entityId: ApplicationId,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
addToLog(app, 'application', ApplicationId, event, body)
addToLog(app, 'application', ApplicationId, event, enrichedBody)
},
projectLog: async function (app, ProjectId, UserId, event, body) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'project',
entityId: ProjectId,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
addToLog(app, 'project', ProjectId, event, body)
addToLog(app, 'project', ProjectId, event, enrichedBody)
},
teamLog: async function (app, TeamId, UserId, event, body) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'team',
entityId: TeamId,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
addToLog(app, 'team', TeamId, event, body)
addToLog(app, 'team', TeamId, event, enrichedBody)
},
deviceLog: async function (app, DeviceId, UserId, event, body) {
const { source, body: enrichedBody } = applySourceContext(body)
await app.db.models.AuditLog.create({
entityType: 'device',
entityId: DeviceId,
UserId,
event,
body: encodeBody(body)
source,
body: encodeBody(enrichedBody)
})
}
}
25 changes: 25 additions & 0 deletions forge/db/migrations/20260714-01-add-audit-source.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* eslint-disable no-unused-vars */
/**
* Add source column to AuditLogs table
*
* Tracks how an audited action was triggered:
* null - legacy / cookie session (UI)
* 'api' - PAT-authenticated direct API call
* 'mcp:expert' - first-party expert agent via MCP
* 'mcp' - third-party MCP agent (future)
*/

const { DataTypes } = require('sequelize')

module.exports = {
up: async (context) => {
await context.addColumn('AuditLogs', 'source', {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null
})
},
down: async (context) => {
await context.removeColumn('AuditLogs', 'source')
}
}
3 changes: 2 additions & 1 deletion forge/db/models/AuditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ module.exports = {
event: { type: DataTypes.STRING },
body: { type: DataTypes.TEXT },
entityId: { type: DataTypes.STRING },
entityType: { type: DataTypes.STRING }
entityType: { type: DataTypes.STRING },
source: { type: DataTypes.STRING, allowNull: true, defaultValue: null }
},
options: {
updatedAt: false
Expand Down
2 changes: 2 additions & 0 deletions forge/db/views/AuditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = function (app) {
createdAt: { type: 'string' },
username: { type: 'string', nullable: true },
event: { type: 'string' },
source: { type: 'string', nullable: true },
scope: { type: 'object', additionalProperties: true },
trigger: { type: 'object', additionalProperties: true },
body: { type: 'object', additionalProperties: true }
Expand Down Expand Up @@ -55,6 +56,7 @@ module.exports = function (app) {
createdAt: e.createdAt,
username: e.User ? e.User.username : null, // Kept for compatibility. To remove once UI complete
event: e.event,
source: e.source || null,
scope,
trigger: sanitiseObjectIds(e.trigger),
body: e.body
Expand Down
3 changes: 3 additions & 0 deletions forge/forge.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ module.exports = async (options = {}) => {
// Request Context: per-request store
await server.register(fastifyRequestContext)

// Nonce Store: in-memory single-use token store
await server.register(require('./lib/nonceStore'))

let contentSecurityPolicy = false
if (runtimeConfig.content_security_policy?.enabled) {
if (!runtimeConfig.content_security_policy.directives) {
Expand Down
104 changes: 104 additions & 0 deletions forge/lib/nonceStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Generic in-memory nonce store.
*
* Each nonce is single-use (consumed on first read) and expires after a
* configurable TTL. Callers attach arbitrary metadata when creating a nonce
* and receive it back on consumption.
*
* In-memory only: intended for process-local trust boundaries such as
* Fastify's app.inject(), where the producer and consumer always live in
* the same Node.js process.
*
Comment thread
hardillb marked this conversation as resolved.
* Registered as a Fastify plugin on `app.nonceStore`.
*/

const crypto = require('crypto')

const fp = require('fastify-plugin')

const DEFAULT_TTL_MS = 30_000 // 30 seconds
const SWEEP_INTERVAL_MS = 60_000 // 1 minute

class NonceStore {
constructor () {
/** @type {Map<string, { metadata: object, expiresAt: number }>} */
this._store = new Map()
this._sweepTimer = setInterval(() => this._sweep(), SWEEP_INTERVAL_MS)
this._sweepTimer.unref()
}

/**
* Create a nonce and associate metadata with it.
* @param {object} metadata - Arbitrary data returned on consume()
* @param {number} [ttl] - Time-to-live in ms (default 30 000)
* @returns {string} The nonce token
*/
create (metadata, ttl = DEFAULT_TTL_MS) {
const nonce = crypto.randomBytes(16).toString('hex')
this._store.set(nonce, {
metadata,
expiresAt: Date.now() + ttl
})
return nonce
}

/**
* Create a nonce for source context propagation.
* Validates that source is present. Generates a correlationId if not provided.
* @param {{ source: string, correlationId?: string, toolName?: string, tokenId?: number }} metadata
* @param {number} [ttl] - Time-to-live in ms (default 30 000)
* @returns {string} The nonce token
*/
createSourceNonce (metadata, ttl) {
if (!metadata?.source) {
throw new Error('source is required for source context nonces')
}
const meta = { ...metadata }
if (!meta.correlationId) {
meta.correlationId = crypto.randomUUID()
}
return this.create(meta, ttl)
}

/**
* Consume a nonce, returning its metadata exactly once.
* Returns null if the nonce does not exist or has expired.
* @param {string} nonce
* @returns {object|null}
*/
consume (nonce) {
const entry = this._store.get(nonce)
if (!entry) {
return null
}
this._store.delete(nonce)
if (entry.expiresAt < Date.now()) {
return null
}
return entry.metadata
}

/** Remove all expired entries. */
_sweep () {
const now = Date.now()
for (const [key, entry] of this._store) {
if (entry.expiresAt < now) {
this._store.delete(key)
}
}
}

/** Stop the periodic sweep timer. */
close () {
clearInterval(this._sweepTimer)
this._store.clear()
}
}

module.exports = fp(async function (app) {
const store = new NonceStore()
app.decorate('nonceStore', store)
app.addHook('onClose', () => store.close())
}, { name: 'app.nonceStore' })

module.exports.NonceStore = NonceStore
19 changes: 19 additions & 0 deletions forge/routes/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ async function init (app, opts) {
* @static
* @memberof forge
*/
function resolveSourceContext (request) {
const nonceHeader = request.headers?.['x-ff-source-nonce']
if (nonceHeader && app.nonceStore) {
const metadata = app.nonceStore.consume(nonceHeader)
if (metadata) {
request.requestContext.set('sourceContext', metadata)
return
}
}
if (request.session?.isPAT) {
request.requestContext.set('sourceContext', {
source: 'api',
tokenId: request.session.pat?.id
})
}
}

async function verifySession (request, reply) {
if (request.sid) {
request.session = await app.db.controllers.Session.getOrExpire(request.sid)
Expand All @@ -75,6 +92,7 @@ async function init (app, opts) {

if (emailVerified && passwordNotExpired && !suspended && !mfaMissing) {
Sentry.setUser({ id: request.session.User.hashid, username: request.session.User.username, email: request.session.User.email, name: request.session.User.name })
resolveSourceContext(request)
return
}
if (request.routeOptions.config.allowAnonymous) {
Expand Down Expand Up @@ -206,6 +224,7 @@ async function init (app, opts) {
reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
return
}
resolveSourceContext(request)
return
}
reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' })
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10321,6 +10321,7 @@ export interface components {
createdAt: string;
username: string | null;
event: string;
source?: string | null;
scope: {
[key: string]: unknown;
};
Expand Down
Loading
Loading