diff --git a/forge/auditLog/formatters.js b/forge/auditLog/formatters.js index 6aaaf97091..7f0f443c91 100644 --- a/forge/auditLog/formatters.js +++ b/forge/auditLog/formatters.js @@ -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 @@ -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 || {} diff --git a/forge/db/controllers/AuditLog.js b/forge/db/controllers/AuditLog.js index c6ec7524ee..58ffbe6979 100644 --- a/forge/db/controllers/AuditLog.js +++ b/forge/db/controllers/AuditLog.js @@ -1,3 +1,5 @@ +const { requestContext } = require('@fastify/request-context') + function encodeBody (body) { if (body !== undefined) { const result = JSON.stringify(body) @@ -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}`) } @@ -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) }) } } diff --git a/forge/db/migrations/20260714-01-add-audit-source.js b/forge/db/migrations/20260714-01-add-audit-source.js new file mode 100644 index 0000000000..8b89c2d040 --- /dev/null +++ b/forge/db/migrations/20260714-01-add-audit-source.js @@ -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') + } +} diff --git a/forge/db/models/AuditLog.js b/forge/db/models/AuditLog.js index 49c3baebbc..afcc495122 100644 --- a/forge/db/models/AuditLog.js +++ b/forge/db/models/AuditLog.js @@ -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 diff --git a/forge/db/views/AuditLog.js b/forge/db/views/AuditLog.js index b74d3a4853..4a311f6f26 100644 --- a/forge/db/views/AuditLog.js +++ b/forge/db/views/AuditLog.js @@ -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 } @@ -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 diff --git a/forge/forge.js b/forge/forge.js index fef9d063a9..d27f0d6d31 100644 --- a/forge/forge.js +++ b/forge/forge.js @@ -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) { diff --git a/forge/lib/nonceStore.js b/forge/lib/nonceStore.js new file mode 100644 index 0000000000..0776489eda --- /dev/null +++ b/forge/lib/nonceStore.js @@ -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. + * + * 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} */ + 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 diff --git a/forge/routes/auth/index.js b/forge/routes/auth/index.js index 220c7cd15a..8bee31959e 100644 --- a/forge/routes/auth/index.js +++ b/forge/routes/auth/index.js @@ -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) @@ -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) { @@ -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' }) diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 2103716288..556d738d7f 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -10321,6 +10321,7 @@ export interface components { createdAt: string; username: string | null; event: string; + source?: string | null; scope: { [key: string]: unknown; }; diff --git a/test/unit/forge/auditLog/formatters_spec.js b/test/unit/forge/auditLog/formatters_spec.js index 48d9353d14..9fe6c95625 100644 --- a/test/unit/forge/auditLog/formatters_spec.js +++ b/test/unit/forge/auditLog/formatters_spec.js @@ -24,6 +24,7 @@ describe('Audit Log > Formatters', async function () { should(entry.User).be.an.Object() should(entry).have.property('event', '') should(entry).have.property('createdAt', '') + should(entry).have.property('source', null) should(entry).have.property('scope') should(entry.scope).have.property('id', '') should(entry.scope).have.property('type', '') @@ -467,6 +468,62 @@ describe('Audit Log > Formatters', async function () { }).throw() }) + describe('Formats audit source', async function () { + it('Includes source field from DB row', async function () { + const entry = Formatters.formatLogEntry({ + hashid: '', + UserId: {}, + User: {}, + event: '', + createdAt: '', + entityId: '', + entityType: '', + source: 'mcp:expert', + body: {} + }) + + should(entry).have.property('source', 'mcp:expert') + }) + + it('Returns null source for entries without source', async function () { + const entry = Formatters.formatLogEntry({ + hashid: '', + UserId: {}, + User: {}, + event: '', + createdAt: '', + entityId: '', + entityType: '', + body: {} + }) + + should(entry).have.property('source', null) + }) + + it('Passes through sourceContext from body', async function () { + const sourceContext = { + toolName: 'get-instance', + correlationId: 'abc-123', + tokenId: 42 + } + const entry = Formatters.formatLogEntry({ + hashid: '', + UserId: {}, + User: {}, + event: '', + createdAt: '', + entityId: '', + entityType: '', + source: 'mcp:expert', + body: JSON.stringify({ sourceContext }) + }) + + should(entry).have.property('source', 'mcp:expert') + should(entry.body).have.property('sourceContext') + should(entry.body.sourceContext).deepEqual(sourceContext) + }) + }) + describe('Formats Node-RED audit log entries', async function () { it('Includes the event data for `flows.set` event', async function () { // can get away with passing empty objects here diff --git a/test/unit/forge/db/controllers/AuditLog_spec.js b/test/unit/forge/db/controllers/AuditLog_spec.js new file mode 100644 index 0000000000..1fe4847b1b --- /dev/null +++ b/test/unit/forge/db/controllers/AuditLog_spec.js @@ -0,0 +1,160 @@ +const { requestContext } = require('@fastify/request-context') +const should = require('should') // eslint-disable-line + +const setup = require('../setup') + +describe('AuditLog controller', function () { + let app + + before(async function () { + app = await setup() + }) + + after(async function () { + await app.close() + }) + + afterEach(async function () { + await app.db.models.AuditLog.destroy({ where: {} }) + }) + + describe('source column', function () { + it('writes null source when no audit context is set', async function () { + await app.db.controllers.AuditLog.platformLog(null, 'test.event', {}) + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(1) + should(entries[0].source).be.null() + }) + + it('writes source from request context', async function () { + const store = requestContext + const origGet = store.get + store.get = (key) => { + if (key === 'sourceContext') { + return { source: 'api' } + } + return origGet.call(store, key) + } + + try { + await app.db.controllers.AuditLog.teamLog('1', null, 'test.event', {}) + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(1) + entries[0].source.should.equal('api') + } finally { + store.get = origGet + } + }) + + it('writes mcp:expert source from request context', async function () { + const store = requestContext + const origGet = store.get + store.get = (key) => { + if (key === 'sourceContext') { + return { + source: 'mcp:expert', + toolName: 'get-instance', + correlationId: 'corr-123', + tokenId: 42 + } + } + return origGet.call(store, key) + } + + try { + await app.db.controllers.AuditLog.projectLog('1', null, 'test.event', {}) + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(1) + entries[0].source.should.equal('mcp:expert') + + const body = JSON.parse(entries[0].body) + should(body.sourceContext).be.an.Object() + body.sourceContext.toolName.should.equal('get-instance') + body.sourceContext.correlationId.should.equal('corr-123') + body.sourceContext.tokenId.should.equal(42) + } finally { + store.get = origGet + } + }) + + it('includes tokenId in sourceContext for PAT API calls', async function () { + const store = requestContext + const origGet = store.get + store.get = (key) => { + if (key === 'sourceContext') { + return { source: 'api', tokenId: 99 } + } + return origGet.call(store, key) + } + + try { + await app.db.controllers.AuditLog.deviceLog('1', null, 'test.event', { device: { id: 1 } }) + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(1) + entries[0].source.should.equal('api') + + const body = JSON.parse(entries[0].body) + should(body.sourceContext).be.an.Object() + body.sourceContext.tokenId.should.equal(99) + should(body.sourceContext.toolName).be.undefined() + should(body.sourceContext.correlationId).be.undefined() + } finally { + store.get = origGet + } + }) + + it('omits sourceContext when no context metadata exists', async function () { + const store = requestContext + const origGet = store.get + store.get = (key) => { + if (key === 'sourceContext') { + return { source: 'api' } + } + return origGet.call(store, key) + } + + try { + await app.db.controllers.AuditLog.deviceLog('1', null, 'test.event', { device: { id: 1 } }) + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(1) + entries[0].source.should.equal('api') + + const body = JSON.parse(entries[0].body) + should(body.sourceContext).be.undefined() + } finally { + store.get = origGet + } + }) + + it('handles all log functions consistently', async function () { + const store = requestContext + const origGet = store.get + store.get = (key) => { + if (key === 'sourceContext') { + return { source: 'mcp:expert', correlationId: 'c1', toolName: 't1', tokenId: 1 } + } + return origGet.call(store, key) + } + + try { + await app.db.controllers.AuditLog.platformLog(null, 'p.event', {}) + await app.db.controllers.AuditLog.userLog(null, 'u.event', {}) + await app.db.controllers.AuditLog.applicationLog('1', null, 'a.event', {}) + await app.db.controllers.AuditLog.projectLog('1', null, 'pr.event', {}) + await app.db.controllers.AuditLog.teamLog('1', null, 'tm.event', {}) + await app.db.controllers.AuditLog.deviceLog('1', null, 'd.event', {}) + + const entries = await app.db.models.AuditLog.findAll() + entries.should.have.length(6) + for (const entry of entries) { + entry.source.should.equal('mcp:expert') + const body = JSON.parse(entry.body) + should(body.sourceContext).be.an.Object() + body.sourceContext.correlationId.should.equal('c1') + } + } finally { + store.get = origGet + } + }) + }) +}) diff --git a/test/unit/forge/lib/nonceStore_spec.js b/test/unit/forge/lib/nonceStore_spec.js new file mode 100644 index 0000000000..2188299eaa --- /dev/null +++ b/test/unit/forge/lib/nonceStore_spec.js @@ -0,0 +1,121 @@ +const should = require('should') // eslint-disable-line + +const { NonceStore } = require('../../../../forge/lib/nonceStore') + +describe('NonceStore', function () { + let store + + beforeEach(function () { + store = new NonceStore() + }) + + afterEach(function () { + store.close() + }) + + describe('create', function () { + it('returns a hex string nonce', function () { + const nonce = store.create({ foo: 'bar' }) + should(nonce).be.a.String() + nonce.should.match(/^[a-f0-9]{32}$/) + }) + + it('returns unique nonces on each call', function () { + const a = store.create({ a: 1 }) + const b = store.create({ b: 2 }) + a.should.not.equal(b) + }) + }) + + describe('consume', function () { + it('returns the stored metadata', function () { + const metadata = { source: 'mcp:expert', toolName: 'test-tool' } + const nonce = store.create(metadata) + const result = store.consume(nonce) + should(result).deepEqual(metadata) + }) + + it('returns null on second consume (single-use)', function () { + const nonce = store.create({ source: 'mcp:expert' }) + store.consume(nonce) + const second = store.consume(nonce) + should(second).be.null() + }) + + it('returns null for unknown nonce', function () { + const result = store.consume('nonexistent') + should(result).be.null() + }) + + it('returns null for expired nonce', function (done) { + const nonce = store.create({ source: 'mcp:expert' }, 50) // 50ms TTL + setTimeout(() => { + const result = store.consume(nonce) + should(result).be.null() + done() + }, 100) + }) + + it('returns metadata for non-expired nonce', function () { + const metadata = { source: 'mcp:expert' } + const nonce = store.create(metadata, 5000) // 5s TTL + const result = store.consume(nonce) + should(result).deepEqual(metadata) + }) + }) + + describe('custom TTL', function () { + it('accepts a custom TTL per nonce', function (done) { + const nonce = store.create({ source: 'test' }, 50) + // Immediately should work + should(store.consume(nonce)).not.be.null() + + // Create another with same short TTL + const nonce2 = store.create({ source: 'test2' }, 50) + setTimeout(() => { + should(store.consume(nonce2)).be.null() + done() + }, 100) + }) + }) + + describe('createSourceNonce', function () { + it('creates a nonce when required fields are present', function () { + const metadata = { source: 'mcp:expert', correlationId: 'abc-123', toolName: 'get-instance', tokenId: 42 } + const nonce = store.createSourceNonce(metadata) + should(nonce).be.a.String() + const result = store.consume(nonce) + should(result).deepEqual(metadata) + }) + + it('throws when source is missing', function () { + should(() => { + store.createSourceNonce({ correlationId: 'abc-123' }) + }).throw('source is required for source context nonces') + }) + + it('generates correlationId when not provided', function () { + const metadata = { source: 'mcp:expert' } + const nonce = store.createSourceNonce(metadata) + const result = store.consume(nonce) + should(result.source).equal('mcp:expert') + should(result.correlationId).be.a.String() + result.correlationId.should.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + }) + + it('does not mutate the caller metadata object', function () { + const metadata = { source: 'mcp:expert' } + store.createSourceNonce(metadata) + should(metadata.correlationId).be.undefined() + }) + }) + + describe('close', function () { + it('clears all entries', function () { + const nonce = store.create({ source: 'test' }) + store.close() + const result = store.consume(nonce) + should(result).be.null() + }) + }) +}) diff --git a/test/unit/forge/routes/auth/index_spec.js b/test/unit/forge/routes/auth/index_spec.js index b3030b6822..83772fa6e1 100644 --- a/test/unit/forge/routes/auth/index_spec.js +++ b/test/unit/forge/routes/auth/index_spec.js @@ -1170,4 +1170,125 @@ describe('Accounts API', async function () { response.cookies[0].maxAge.should.equal(300) }) }) + + describe('Source context resolution', async function () { + // These tests verify that resolveSourceContext in the auth layer + // correctly populates (or does not populate) the source context + // based on the x-ff-source-nonce header or PAT session. + // Verification is done by triggering an audit log write (PUT /api/v1/user) + // and checking the AuditLog source column. + let nameCounter = 0 + + before(async function () { + app = await setup() + }) + after(async function () { + await app.close() + }) + + async function getLatestAuditEntry () { + return app.db.models.AuditLog.findOne({ + order: [['createdAt', 'DESC']] + }) + } + + async function triggerAuditViaUserUpdate (authOpts) { + nameCounter++ + return app.inject({ + method: 'PUT', + url: '/api/v1/user', + payload: { name: `Source Test User ${nameCounter}` }, + ...authOpts + }) + } + + it('bogus nonce does not populate source', async function () { + await login('alice', 'aaPassword') + const response = await triggerAuditViaUserUpdate({ + cookies: { sid: TestObjects.tokens.alice }, + headers: { 'x-ff-source-nonce': 'not-a-real-nonce' } + }) + response.statusCode.should.equal(200) + const entry = await getLatestAuditEntry() + should(entry.source).be.null() + }) + + it('expired nonce does not populate source', async function () { + await login('alice', 'aaPassword') + const nonce = app.nonceStore.createSourceNonce({ source: 'mcp:expert', toolName: 'test-tool' }, 1) + // Wait for the nonce to expire + await new Promise(resolve => setTimeout(resolve, 50)) + const response = await triggerAuditViaUserUpdate({ + cookies: { sid: TestObjects.tokens.alice }, + headers: { 'x-ff-source-nonce': nonce } + }) + response.statusCode.should.equal(200) + const entry = await getLatestAuditEntry() + should(entry.source).be.null() + }) + + it('valid nonce populates source from nonce metadata', async function () { + await login('alice', 'aaPassword') + const nonce = app.nonceStore.createSourceNonce({ + source: 'mcp:expert', + correlationId: 'test-corr-id', + toolName: 'get-instance' + }) + const response = await triggerAuditViaUserUpdate({ + cookies: { sid: TestObjects.tokens.alice }, + headers: { 'x-ff-source-nonce': nonce } + }) + response.statusCode.should.equal(200) + const entry = await getLatestAuditEntry() + entry.source.should.equal('mcp:expert') + const body = JSON.parse(entry.body) + body.sourceContext.should.have.property('correlationId', 'test-corr-id') + body.sourceContext.should.have.property('toolName', 'get-instance') + }) + + it('PAT without nonce gets source api with tokenId', async function () { + const patUser = await app.factory.createUser({ + username: 'patUser', + name: 'PAT User', + email: 'pat@example.com', + password: 'ppPassword' + }) + const pat = await app.db.controllers.AccessToken.createPersonalAccessToken( + patUser, '', null, 'Test PAT' + ) + const response = await triggerAuditViaUserUpdate({ + headers: { authorization: `Bearer ${pat.token}` } + }) + response.statusCode.should.equal(200) + const entry = await getLatestAuditEntry() + entry.source.should.equal('api') + const body = JSON.parse(entry.body) + body.sourceContext.should.have.property('tokenId') + }) + + it('nonce is single-use and does not populate source on reuse', async function () { + await login('alice', 'aaPassword') + const nonce = app.nonceStore.createSourceNonce({ + source: 'mcp:expert', + correlationId: 'single-use-test' + }) + // First request consumes the nonce + const first = await triggerAuditViaUserUpdate({ + cookies: { sid: TestObjects.tokens.alice }, + headers: { 'x-ff-source-nonce': nonce } + }) + first.statusCode.should.equal(200) + const firstEntry = await getLatestAuditEntry() + firstEntry.source.should.equal('mcp:expert') + + // Second request with same nonce should not get source + const second = await triggerAuditViaUserUpdate({ + cookies: { sid: TestObjects.tokens.alice }, + headers: { 'x-ff-source-nonce': nonce } + }) + second.statusCode.should.equal(200) + const secondEntry = await getLatestAuditEntry() + should(secondEntry.source).be.null() + }) + }) })