diff --git a/api/controllers/agent/decide.js b/api/controllers/agent/decide.js index ef2e00d..de9c87e 100644 --- a/api/controllers/agent/decide.js +++ b/api/controllers/agent/decide.js @@ -19,7 +19,7 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.update')) { + if (!sails.helpers.audit.can(req, 'host.update')) { return res.forbidden(); } const action = req.param('action'); @@ -34,11 +34,13 @@ module.exports = { const by = req.user.username; if (action === 'deny') { await Enrollment.updateOne({id: row.id}).set({state: 'denied', cert: '', decidedAt: Date.now(), decidedBy: by, decidedVia: 'admin'}); + // The 1.6 trail text (Enrollment::deny). The row is the admin's. + await sails.helpers.agent.audit.with({req, row, text: `denied by ${by}`, by}); return res.json({id: row.id, hostID: row.host, state: 'denied'}); } try { - await sails.helpers.agent.issue.with({row, via: 'admin', by, keepCert: true}); + await sails.helpers.agent.issue.with({row, via: 'admin', by, keepCert: true, req}); } catch (err) { if (err.exit === 'notBound') { return res.status(409).json({error: 'enrollment is not bound to a host'}); } return res.status(503).json({error: 'signing'}); diff --git a/api/controllers/agent/enroll.js b/api/controllers/agent/enroll.js index 2a5df18..66e22ca 100644 --- a/api/controllers/agent/enroll.js +++ b/api/controllers/agent/enroll.js @@ -96,10 +96,16 @@ module.exports = { not_after: result.notAfter }); const signing = () => res.status(503).json({status: 'error', reason: 'signing'}); - // Run the automatic approval; answer 200, 503 or (when it waits) 202. - const autoApprove = async (row) => { + // The 1.6 audit row of a transition (Enrollment::_audit), as the agent. + const audit = (row, what) => sails.helpers.agent.audit.with({req, row, text: what, by: ''}); + // Run the automatic approval; answer 200, 503 or (when it waits) 202. On a + // first contact that waits, 1.6 records why (a repeat records nothing). + const autoApprove = async (row, firstContact = false) => { try { - const result = await sails.helpers.agent.autoApprove.with({row, token: text(body.token, MAX_TOKEN)}); + const result = await sails.helpers.agent.autoApprove.with({row, token: text(body.token, MAX_TOKEN), req}); + if (!result && firstContact) { + await audit(row, row.reason === 'no-mac' ? 'waiting: no MAC address reported' : `waiting: ${row.reason}`); + } return result ? issued(result) : pending(row); } catch (unused) { return signing(); @@ -148,7 +154,9 @@ module.exports = { } if (found.state === 'issued') { // The agent lost its certificate but kept its key. An admin looks at it. - return pending(await Enrollment.updateOne({id: found.id}).set({state: 'pending', reason: 'reissue'})); + const again = await Enrollment.updateOne({id: found.id}).set({state: 'pending', reason: 'reissue'}); + await audit(again, 'waiting: reissue'); + return pending(again); } // Still pending: refresh the row, and re-check the automatic paths. A // token may be valid now, or the deploy may have finished. @@ -195,6 +203,6 @@ module.exports = { }); row = await Enrollment.updateOne({id: row.id}).set({host: host.id}); } - return await autoApprove(row); + return await autoApprove(row, true); } }; diff --git a/api/controllers/agent/enrollments.js b/api/controllers/agent/enrollments.js index 95cf4bd..1ce961a 100644 --- a/api/controllers/agent/enrollments.js +++ b/api/controllers/agent/enrollments.js @@ -11,7 +11,7 @@ module.exports = { description: 'List the pending agent enrollment requests.', fn: async function () { const res = this.res; - if (!_.get(this.req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(this.req, 'host.read')) { return res.forbidden(); } const rows = await sails.models.agentenrollment.find({state: 'pending'}).sort('createdAt ASC'); diff --git a/api/controllers/agent/mint-token.js b/api/controllers/agent/mint-token.js index b326d1e..8f06be7 100644 --- a/api/controllers/agent/mint-token.js +++ b/api/controllers/agent/mint-token.js @@ -16,7 +16,7 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.create')) { + if (!sails.helpers.audit.can(req, 'host.create')) { return res.forbidden(); } const bad = (error) => res.status(400).json({error}); @@ -41,6 +41,18 @@ module.exports = { expires, createdBy: req.user.username }).fetch(); + // The 1.6 trail text (Token::mint). Never the token or its hash. `expires` + // is UTC "YYYY-MM-DD HH:MM:SS", as the not_after of the enroll answer is. + await sails.helpers.audit.record.with({req, row: { + type: 'agent.token', + subjectType: 'agentenrolltoken', + subjectId: row.id, + subjectLabel: name, + permission: 'host.create', + outcome: 'allowed', + renderable: true, + text: `minted, ${uses === -1 ? 'unlimited uses' : `${uses} use(s)`}, expires ${new Date(expires).toISOString().slice(0, 19).replace('T', ' ')}` + }}); return res.json({id: row.id, name, token, expires}); } }; diff --git a/api/controllers/agent/revoke-token.js b/api/controllers/agent/revoke-token.js index 6bbc295..8b43286 100644 --- a/api/controllers/agent/revoke-token.js +++ b/api/controllers/agent/revoke-token.js @@ -11,11 +11,22 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.destroy')) { + if (!sails.helpers.audit.can(req, 'host.destroy')) { return res.forbidden(); } const row = await sails.models.agentenrolltoken.destroyOne({id: req.param('id')}); if (!row) { return res.status(404).json({error: 'no such token'}); } + // The 1.6 trail text (Token::revoke). The name survives in the row. + await sails.helpers.audit.record.with({req, row: { + type: 'agent.token', + subjectType: 'agentenrolltoken', + subjectId: row.id, + subjectLabel: row.name, + permission: 'host.destroy', + outcome: 'allowed', + renderable: true, + text: `revoked by ${req.user.username}` + }}); return res.json({id: row.id}); } }; diff --git a/api/controllers/agent/tokens.js b/api/controllers/agent/tokens.js index 1c39e95..64ce90b 100644 --- a/api/controllers/agent/tokens.js +++ b/api/controllers/agent/tokens.js @@ -10,7 +10,7 @@ module.exports = { description: 'List the agent enrollment tokens.', fn: async function () { const res = this.res; - if (!_.get(this.req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(this.req, 'host.read')) { return res.forbidden(); } const now = Date.now(); diff --git a/api/controllers/auth/login.js b/api/controllers/auth/login.js index 7d6b125..6571ca3 100644 --- a/api/controllers/auth/login.js +++ b/api/controllers/auth/login.js @@ -11,12 +11,26 @@ module.exports = { fn: async function (inputs, exits) { let req = this.req; let res = this.res; + // The attempted name for a refused sign-in row: trimmed, cut to the 191 + // characters of a 1.6 user name. + const attempted = String(req.param('username') || '').trim().slice(0, 191); + // The auth.login.failed row. No reason is stored (1.6 stores none: it + // would help enumerate users). Only the outcome differs. + const recordFailed = (outcome) => sails.helpers.audit.record.with({req, row: { + type: 'auth.login.failed', outcome, subjectType: 'user', subjectId: '', + subjectLabel: attempted, createdBy: attempted, authSource: 'local', text: '' + }}); await passport.authenticate(sails.config.globals.authenticationMechanisms, async (err, user, info) => { - if (err) return exits.error(err); + if (err) { + // A strategy error (the database) is a failed sign-in, not a refused one. + await recordFailed('failed'); + return exits.error(err); + } // No user => bad credentials. Return a clean 401 (JSON) or bounce back to // the login form, instead of calling req.login(undefined) which throws a // 500 ("Failed to serialize user into session"). if (!user) { + await recordFailed('denied'); let message = (info && info.message) || 'Invalid username or password'; if (req.wantsJSON) { res.status(401); @@ -30,6 +44,10 @@ module.exports = { } await req.login(user, async (err) => { if (err) return exits.error(err); + await sails.helpers.audit.record.with({req, row: { + type: 'auth.login', outcome: 'allowed', subjectType: 'user', subjectId: user.id, + subjectLabel: user.username, createdBy: user.username, authSource: 'local' + }}); await jwt.sign( {user: user.id}, sails.config.auth.jwt.secret, diff --git a/api/controllers/auth/logout.js b/api/controllers/auth/logout.js index 654a595..470c85e 100644 --- a/api/controllers/auth/logout.js +++ b/api/controllers/auth/logout.js @@ -8,6 +8,12 @@ module.exports = { fn: async function () { let req = this.req; let res = this.res; + // Record the sign-out BEFORE req.logout: after it the user is gone and the + // row would name nobody. + await sails.helpers.audit.record.with({req, row: { + type: 'auth.logout', outcome: 'allowed', subjectType: 'user', + subjectId: req.user.id, subjectLabel: req.user.username + }}); // passport >= 0.6 made req.logout() asynchronous -- it requires a callback // and throws (500) if called the old synchronous way. Await it so the action // doesn't return (and send a default response) before the redirect. diff --git a/api/controllers/general/create.js b/api/controllers/general/create.js index e246f49..e33df73 100644 --- a/api/controllers/general/create.js +++ b/api/controllers/general/create.js @@ -31,6 +31,8 @@ module.exports = { // a host's groups change that the caller may not make (403). let check = await sails.helpers.group.checkSave.with({model, values: params, permissions: req.user.permissions}); if (check.error) { + // A refusal names the group permission that refused it (the audit trail). + if (check.error.permission) { _.set(req, 'audit.permission', check.error.permission); } throw {[{400: 'badRequest', 403: 'forbidden', 409: 'conflict'}[check.error.status]]: {message: check.error.message}}; } let obj = await sails.models[model].create(params) @@ -45,6 +47,8 @@ module.exports = { return {badRequest: err}; }) .fetch(); + // The audit trail: the row that this request wrote (api/hooks/audit). + sails.helpers.audit.subject(req, model, obj); return await sails.models[model].findOne({id: obj.id}).populateAll(); } }; diff --git a/api/controllers/general/destroy.js b/api/controllers/general/destroy.js index ac8b840..c5b45e8 100644 --- a/api/controllers/general/destroy.js +++ b/api/controllers/general/destroy.js @@ -10,6 +10,14 @@ module.exports = { id = [id]; } let destroyed = await sails.models[model].destroy({id}).fetch(); + // The audit trail (api/hooks/audit): one row for the request. One id names + // the row; several ids give the count, with no subject id. + if (id.length > 1) { + sails.helpers.audit.subject(req, model); + req.audit.affectedCount = destroyed.length; + } else { + sails.helpers.audit.subject(req, model, destroyed[0], id[0]); + } // A destroyed Group leaves its members: the name goes from each host's // groups, and no host is deleted. if (model === 'group') { diff --git a/api/controllers/general/save.js b/api/controllers/general/save.js index 71f9e64..261d7e1 100644 --- a/api/controllers/general/save.js +++ b/api/controllers/general/save.js @@ -27,7 +27,10 @@ module.exports = { // Permission: the generic create/update policies can't run here (no :model // param), so check the relevant permission for the derived model directly. let perm = isUpdate ? 'update' : 'create'; - if (!_.get(req, 'user.permissions.stock.' + model + '.' + perm)) { + // The audit trail (api/hooks/audit): the permission before the decision, + // and the row once it is known. + sails.helpers.audit.subject(req, model, null, id); + if (!sails.helpers.audit.can(req, model + '.' + perm)) { res.forbidden(); return res.end(); } @@ -123,7 +126,11 @@ module.exports = { model, id: isUpdate ? id : undefined, values, permissions: req.user.permissions }); if (check.error) { - if (check.error.status === 403) { failStatus = 403; } + if (check.error.status === 403) { + failStatus = 403; + // The group permission that refused it (the audit trail). + _.set(req, 'audit.permission', check.error.permission); + } throw new Error(check.error.message); } oldGroup = check.oldGroup; @@ -133,6 +140,7 @@ module.exports = { if (isUpdate) { let saved = await sails.models[model].updateOne({ id }).set(values); exists = !!saved; + if (saved) { sails.helpers.audit.subject(req, model, saved); } // The members keep their membership through a rename, as in 1.6. if (oldGroup && saved && saved.name !== oldGroup.name) { await sails.helpers.group.rename.with({from: oldGroup.name, to: saved.name}); @@ -140,6 +148,7 @@ module.exports = { } else { let created = await sails.models[model].create(values).fetch(); recordId = created.id; + sails.helpers.audit.subject(req, model, created); } // Apply many-to-many associations (replace == authoritative set). for (let assoc of _.keys(collections)) { @@ -161,6 +170,10 @@ module.exports = { if (model === 'group' && err && err.code === 'E_UNIQUE') { msg = isUpdate ? sails.models.group.takenText : sails.models.group.existsText; } + // The audit trail: a full-page failure answers 302, which the seam + // would read as allowed. A refused group change is denied, any other + // error is failed. + req.audit.outcome = failStatus === 403 ? 'denied' : 'failed'; // Modal/AJAX submit -> JSON, and the modal shows the message itself; // full-page submit -> back to the form, with the message as a toast on // the next page (api/helpers/flash.js). diff --git a/api/controllers/general/update.js b/api/controllers/general/update.js index e24ac32..c976590 100644 --- a/api/controllers/general/update.js +++ b/api/controllers/general/update.js @@ -31,8 +31,12 @@ module.exports = { // A Group name that is not text (400) or is taken without case (409), or // a host's groups change that the caller may not make (403). oldGroup is // the Group before a rename. + // The audit trail names the row (api/hooks/audit); the label follows the write. + sails.helpers.audit.subject(req, model, null, id); let check = await sails.helpers.group.checkSave.with({model, id, values: params, permissions: req.user.permissions}); if (check.error) { + // A refusal names the group permission that refused it (the audit trail). + if (check.error.permission) { _.set(req, 'audit.permission', check.error.permission); } throw {[{400: 'badRequest', 403: 'forbidden', 409: 'conflict'}[check.error.status]]: {message: check.error.message}}; } let oldGroup = check.oldGroup; @@ -46,6 +50,7 @@ module.exports = { .intercept({name: 'UsageError'}, (err) => { return {badRequest: err}; }); + if (obj) { sails.helpers.audit.subject(req, model, obj); } // The members keep their membership through a rename, as in 1.6. if (oldGroup && obj && obj.name !== oldGroup.name) { await sails.helpers.group.rename.with({from: oldGroup.name, to: obj.name}); diff --git a/api/controllers/host/bulk.js b/api/controllers/host/bulk.js index eac2cfc..8692693 100644 --- a/api/controllers/host/bulk.js +++ b/api/controllers/host/bulk.js @@ -32,14 +32,21 @@ module.exports = { // included. let failTitle = (addGroups.length || !removeGroups.length) ? 'Add Hosts to Group Fail' : 'Remove Hosts from Groups Fail'; + // The audit trail (api/hooks/audit): one row for the request. The + // permission goes in before each check, so a denial carries it. A + // membership change consults group.update; a refusal then sets the group + // permission that refused. The count follows the writes. + sails.helpers.audit.subject(req, 'host'); + _.set(req, 'audit.permission', (hostFields || !membership) ? 'host.update' : 'group.update'); // A call that changes no membership keeps its host.update check. - if ((hostFields || !membership) && !_.get(permissions, 'stock.host.update')) { + if ((hostFields || !membership) && !sails.helpers.audit.can(req, 'host.update')) { return res.forbidden(); } if (membership) { let denied = await sails.helpers.group.authorize.with({permissions, names: addGroups}); if (denied) { - return res.status(403).json({message: denied, title: failTitle}); + req.audit.permission = denied.permission; + return res.status(403).json({message: denied.message, title: failTitle}); } } @@ -104,13 +111,15 @@ module.exports = { let names = _.uniq(written, (n) => String(n).toLowerCase()); let denied = await sails.helpers.group.authorize.with({permissions, names}); if (denied) { - return res.status(403).json({message: denied, title: failTitle}); + req.audit.permission = denied.permission; + return res.status(403).json({message: denied.message, title: failTitle}); } } for (let w of writes) { await sails.models.host.updateOne({ id: w.id }).set(w.set); updated++; } + req.audit.affectedCount = updated; // The 1.6 success toast (HostManagement.php ~6950-6970). A removal counts // the named Groups that exist. diff --git a/api/controllers/host/groups.js b/api/controllers/host/groups.js index 1704360..4f74578 100644 --- a/api/controllers/host/groups.js +++ b/api/controllers/host/groups.js @@ -13,7 +13,7 @@ module.exports = { let req = this.req; let res = this.res; - if (!_.get(req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(req, 'host.read')) { return res.forbidden(); } diff --git a/api/controllers/host/pending-approve.js b/api/controllers/host/pending-approve.js index f573d19..ea5c2c9 100644 --- a/api/controllers/host/pending-approve.js +++ b/api/controllers/host/pending-approve.js @@ -12,7 +12,7 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.update')) { + if (!sails.helpers.audit.can(req, 'host.update')) { return res.forbidden(); } const raw = _.get(req.body, 'ids'); diff --git a/api/controllers/host/pending-datatable.js b/api/controllers/host/pending-datatable.js index 58452c9..0b59a97 100644 --- a/api/controllers/host/pending-datatable.js +++ b/api/controllers/host/pending-datatable.js @@ -16,7 +16,7 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(req, 'host.read')) { return res.forbidden(); } const body = req.body || {}; diff --git a/api/controllers/host/pending-delete.js b/api/controllers/host/pending-delete.js index 0187176..1c21e1d 100644 --- a/api/controllers/host/pending-delete.js +++ b/api/controllers/host/pending-delete.js @@ -14,7 +14,7 @@ module.exports = { fn: async function () { const req = this.req; const res = this.res; - if (!_.get(req, 'user.permissions.stock.host.destroy')) { + if (!sails.helpers.audit.can(req, 'host.destroy')) { return res.forbidden(); } const raw = req.allParams().id; diff --git a/api/controllers/image/scan.js b/api/controllers/image/scan.js index d2030c0..5c32461 100644 --- a/api/controllers/image/scan.js +++ b/api/controllers/image/scan.js @@ -4,10 +4,9 @@ module.exports = { fn: async function () { let req = this.req; let res = this.res; - let perms = req.user && req.user.permissions && req.user.permissions.stock && req.user.permissions.stock.image; // Creating image records, so require the image create permission. - if (!perms || !perms.create) { return res.forbidden(); } + if (!sails.helpers.audit.can(req, 'image.create')) { return res.forbidden(); } return res.json(await sails.helpers.image.scanStore()); } diff --git a/api/controllers/pages/list/agent-tokens.js b/api/controllers/pages/list/agent-tokens.js index 286241d..8bc9a59 100644 --- a/api/controllers/pages/list/agent-tokens.js +++ b/api/controllers/pages/list/agent-tokens.js @@ -23,7 +23,7 @@ module.exports = { } }, fn: async function () { - if (!_.get(this.req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(this.req, 'host.read')) { throw 'forbidden'; } let data = { diff --git a/api/controllers/pages/list/pending-agents.js b/api/controllers/pages/list/pending-agents.js index 50acb4f..6e1ffe5 100644 --- a/api/controllers/pages/list/pending-agents.js +++ b/api/controllers/pages/list/pending-agents.js @@ -22,7 +22,7 @@ module.exports = { } }, fn: async function () { - if (!_.get(this.req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(this.req, 'host.read')) { throw 'forbidden'; } let data = { diff --git a/api/controllers/pages/list/pending-hosts.js b/api/controllers/pages/list/pending-hosts.js index 66cea34..db30884 100644 --- a/api/controllers/pages/list/pending-hosts.js +++ b/api/controllers/pages/list/pending-hosts.js @@ -22,7 +22,7 @@ module.exports = { } }, fn: async function () { - if (!_.get(this.req, 'user.permissions.stock.host.read')) { + if (!sails.helpers.audit.can(this.req, 'host.read')) { throw 'forbidden'; } let data = { diff --git a/api/controllers/user/reset-api-token.js b/api/controllers/user/reset-api-token.js index cbab803..bd370e8 100644 --- a/api/controllers/user/reset-api-token.js +++ b/api/controllers/user/reset-api-token.js @@ -12,8 +12,10 @@ module.exports = { }, fn: async function (inputs, exits) { let req = this.req; + // can() first: it notes the permission, so the API-token refusal carries it too. + let mayUpdate = sails.helpers.audit.can(req, 'user.update'); if (req.authVia === 'apitoken') return exits.forbidden(); - if (!_.get(req, 'user.permissions.stock.user.update')) return exits.forbidden(); + if (!mayUpdate) return exits.forbidden(); let target = await User.findOne({ id: inputs.id }); if (!target) return exits.notFound(); let token = 'fpat_' + crypto.randomBytes(24).toString('hex'); diff --git a/api/controllers/user/reset-password.js b/api/controllers/user/reset-password.js index 58cb443..27b788c 100644 --- a/api/controllers/user/reset-password.js +++ b/api/controllers/user/reset-password.js @@ -14,9 +14,11 @@ module.exports = { }, fn: async function (inputs, exits) { let req = this.req; + // can() first: it notes the permission, so the API-token refusal carries it too. + let mayUpdate = sails.helpers.audit.can(req, 'user.update'); // Password changes are never permitted over an API token. if (req.authVia === 'apitoken') return exits.forbidden(); - if (!_.get(req, 'user.permissions.stock.user.update')) return exits.forbidden(); + if (!mayUpdate) return exits.forbidden(); if (inputs.password !== inputs.passwordConfirm) return exits.badRequest({ message: 'Passwords do not match.' }); let target = await User.findOne({ id: inputs.id }); if (!target) return exits.notFound(); diff --git a/api/helpers/agent/audit.js b/api/helpers/agent/audit.js new file mode 100644 index 0000000..5c5532b --- /dev/null +++ b/api/helpers/agent/audit.js @@ -0,0 +1,55 @@ +/** + * Record an `agent.enroll` audit row for an enrollment transition, as 1.6 does + * (Agent/Enrollment.php `_audit`). Every decision leaves a row. + * + * The subject is the bound host (its name is the label), or the row's hostname + * when the row has no host. The row is renderable (the 1.6 activity feed). + * `by` is the admin who decided: the row is theirs, through the credential of + * the request. With no `by` the agent itself caused it on the public route: + * the row is `(system)` with the authSource `anonymous`. + * + * Inputs: req (the request, or null), row (an AgentEnrollment record), text + * (the 1.6 text, e.g. `waiting: reissue`), by (the admin's user name, '' for + * the agent). It never throws, as audit/record never does. + * + * Usage: await sails.helpers.agent.audit.with({req, row, text: 'waiting: reissue', by: ''}) + */ +module.exports = { + friendlyName: 'Audit an agent enrollment transition', + description: 'Write the agent.enroll audit row of an enrollment transition.', + inputs: { + req: {type: 'ref', defaultsTo: null}, + row: {type: 'ref', required: true}, + text: {type: 'string', required: true}, + by: {type: 'string', defaultsTo: ''} + }, + exits: { + success: {description: 'The row was written, or the write failed and was logged.'} + }, + fn: async function ({req, row, text, by}) { + let label = row.hostname || ''; + try { + const host = row.host ? await sails.models.host.findOne({id: row.host}) : undefined; + if (host) { label = host.name; } + } catch (err) { + sails.log.warn(`audit: could not read the host of an enrollment: ${err.message}`); + } + const entry = { + type: 'agent.enroll', + subjectType: 'host', + subjectId: row.host || '', + subjectLabel: label, + renderable: true, + // 1.6 records these with its default outcome, allowed. + outcome: 'allowed', + text + }; + if (by) { + entry.createdBy = by; + } else { + entry.createdBy = '(system)'; + entry.authSource = 'anonymous'; + } + await sails.helpers.audit.record.with({req, row: entry}); + } +}; diff --git a/api/helpers/agent/auto-approve.js b/api/helpers/agent/auto-approve.js index 5a96579..5b96092 100644 --- a/api/helpers/agent/auto-approve.js +++ b/api/helpers/agent/auto-approve.js @@ -17,7 +17,8 @@ * * A row with no host (no-mac) never auto-approves. * - * Inputs: row (an AgentEnrollment record), token (any text, '' for none). + * Inputs: row (an AgentEnrollment record), token (any text, '' for none), req + * (optional, for the audit row that issue writes). * Output: null when the request waits, else {pem, agentId, notAfter} from issue. * Exit signing: an approval applied but the issue failed. The row stays pending. * @@ -31,13 +32,14 @@ module.exports = { description: 'Issue the certificate at once when a valid token or a recent deploy vouches for the host.', inputs: { row: {type: 'ref', required: true}, - token: {type: 'string', defaultsTo: ''} + token: {type: 'string', defaultsTo: ''}, + req: {type: 'ref', defaultsTo: null, description: 'The request, for the audit row of an issue.'} }, exits: { success: {outputType: 'ref', description: 'null, or {pem, agentId, notAfter}.'}, signing: {description: 'The issue failed.'} }, - fn: async function ({row, token}) { + fn: async function ({row, token, req}) { if (!row.host) { return null; } // Reserve a token use. @@ -73,7 +75,7 @@ module.exports = { if (!via) { return null; } try { - return await sails.helpers.agent.issue.with({row, via, by: '', keepCert: false}); + return await sails.helpers.agent.issue.with({row, via, by: '', keepCert: false, req}); } catch (unused) { if (reserved) { await tokens.updateOne({hash}, {$inc: {uses: 1}}); } throw 'signing'; diff --git a/api/helpers/agent/issue.js b/api/helpers/agent/issue.js index 96ab3d2..6747424 100644 --- a/api/helpers/agent/issue.js +++ b/api/helpers/agent/issue.js @@ -16,7 +16,8 @@ * agent in the same request and leaves `cert` ''. * * Inputs: row (an AgentEnrollment record), via (token, deploy or admin), by - * (the admin's user name, '' when automatic), keepCert. + * (the admin's user name, '' when automatic), keepCert, req (optional, for the + * audit row `agent.enroll` that it writes). * Output {pem, agentId, notAfter}: pem is the leaf then the agent CA. * * Exits: @@ -35,14 +36,15 @@ module.exports = { row: {type: 'ref', required: true}, via: {type: 'string', required: true}, by: {type: 'string', defaultsTo: ''}, - keepCert: {type: 'boolean', defaultsTo: false} + keepCert: {type: 'boolean', defaultsTo: false}, + req: {type: 'ref', defaultsTo: null, description: 'The request, for the audit row.'} }, exits: { success: {outputType: 'ref', description: '{pem, agentId, notAfter}'}, notBound: {description: 'The row has no host, or the host is gone.'}, signing: {description: 'The signing failed.'} }, - fn: async function ({row, via, by, keepCert}) { + fn: async function ({row, via, by, keepCert, req}) { const Host = sails.models.host; const host = row.host ? await Host.findOne({id: row.host}) : undefined; if (!host) { throw 'notBound'; } @@ -89,6 +91,10 @@ module.exports = { decidedVia: via, cert: keepCert ? pem : '' }); + // The 1.6 trail text (Enrollment::_issue): how, and which key. + await sails.helpers.agent.audit.with({ + req, row, by, text: `issued via ${via}, key ${row.fingerprint.slice(0, 16)}` + }); return {pem, agentId, notAfter}; } }; diff --git a/api/helpers/audit/can.js b/api/helpers/audit/can.js new file mode 100644 index 0000000..d32e1b1 --- /dev/null +++ b/api/helpers/audit/can.js @@ -0,0 +1,26 @@ +/** + * Check a permission of the request's user, and record the permission it + * consulted in req.audit.permission, for the audit trail (api/hooks/audit). + * The permission goes in before the answer, so a denial carries it. + * + * Inputs: req, permission (`.`, e.g. host.update). + * Output: true when req.user holds it (req.user.permissions.stock..). + * + * Usage: if (!sails.helpers.audit.can(req, 'host.update')) { return res.forbidden(); } + */ +module.exports = { + friendlyName: 'Check and note a permission', + description: 'Set req.audit.permission and tell whether the user holds the permission.', + sync: true, + inputs: { + req: {type: 'ref', required: true}, + permission: {type: 'string', required: true} + }, + exits: { + success: {outputType: 'boolean', description: 'Whether the user holds the permission.'} + }, + fn: function ({req, permission}) { + _.set(req, 'audit.permission', permission); + return Boolean(_.get(req, 'user.permissions.stock.' + permission)); + } +}; diff --git a/api/helpers/audit/ensure-indexes.js b/api/helpers/audit/ensure-indexes.js new file mode 100644 index 0000000..849e534 --- /dev/null +++ b/api/helpers/audit/ensure-indexes.js @@ -0,0 +1,31 @@ +/** + * The indexes of the audit trail. The models use `migrate: 'safe'`, so the ORM + * creates no index. config/bootstrap.js runs this at each lift. createIndex + * does nothing when the index exists, so a new install and an upgraded install + * both get them (the pattern of api/helpers/agent/ensure-indexes.js). No schema + * revision. + * + * createdAt, createdBy, correlationId, outcome plain + * {subjectType, subjectId} plain, compound + * + * No index is unique: 1.6 notes that a unique key silently merges identical + * rows, and two identical audit rows are two events. + * + * Usage: await sails.helpers.audit.ensureIndexes() + */ +module.exports = { + friendlyName: 'Ensure the audit indexes', + description: 'Create the AuditLog indexes when they do not exist.', + exits: { + success: {description: 'The indexes exist.'} + }, + fn: async function () { + const model = sails.models.auditlog; + const collection = sails.getDatastore(model.datastore).manager.collection(model.tableName); + await collection.createIndex({createdAt: 1}); + await collection.createIndex({createdBy: 1}); + await collection.createIndex({correlationId: 1}); + await collection.createIndex({outcome: 1}); + await collection.createIndex({subjectType: 1, subjectId: 1}); + } +}; diff --git a/api/helpers/audit/record.js b/api/helpers/audit/record.js new file mode 100644 index 0000000..d743d22 --- /dev/null +++ b/api/helpers/audit/record.js @@ -0,0 +1,74 @@ +/** + * Write one audit row. The only code that creates an AuditLog row + * (docs/adr/0003-audit-trail.md; 1.6 Audit::record). + * + * Inputs: + * req the request, or null for a machine write with no request + * row the caller keys: type, subjectType, subjectId, subjectLabel, + * permission, outcome, affectedCount, renderable, text, and the + * optional overrides createdBy and authSource + * + * It fills in what the caller did not set: + * createdBy req.user.username, else (system) + * ip req.ip, '' without a request + * authSource req.authVia (apitoken, jwt) when set, else session when + * req.user is set, else anonymous + * correlationId req.correlationId (config/http.js), '' without a request + * subjectId as a string; text is cut to 4096 characters + * + * It never throws: an audit write must not turn a working sign-in into a 500. + * On any error it logs one warning that names the type and returns null. On + * success it returns the row, and sets req.auditRecorded so the request seam + * (api/hooks/audit) adds no second row for the request. + * + * Usage: await sails.helpers.audit.record.with({req, row: {type: 'auth.login', ...}}) + */ +const MAX_TEXT = 4096; +const MACHINE_ACTOR = '(system)'; + +module.exports = { + friendlyName: 'Record an audit row', + description: 'Append one AuditLog row. Never throws; returns the row, or null.', + inputs: { + req: {type: 'ref', defaultsTo: null, description: 'The request, or null.'}, + row: {type: 'ref', required: true, description: 'The caller keys of the row.'} + }, + exits: { + success: {outputType: 'ref', description: 'The stored row, or null when the write failed.'} + }, + fn: async function ({req, row}) { + const type = row && row.type; + try { + const has = (key) => row[key] !== undefined && row[key] !== null; + const text = String(has('text') ? row.text : '').slice(0, MAX_TEXT); + let authSource = 'anonymous'; + if (req && req.authVia) { + authSource = req.authVia; + } else if (req && req.user) { + authSource = 'session'; + } + const values = { + createdBy: has('createdBy') ? String(row.createdBy) : ((req && req.user && req.user.username) || MACHINE_ACTOR), + ip: (req && req.ip) || '', + authSource: has('authSource') ? String(row.authSource) : authSource, + type: String(type), + subjectType: has('subjectType') ? String(row.subjectType) : '', + subjectId: has('subjectId') ? String(row.subjectId) : '', + subjectLabel: has('subjectLabel') ? String(row.subjectLabel) : '', + permission: has('permission') ? String(row.permission) : '', + correlationId: (req && req.correlationId) || '', + affectedCount: has('affectedCount') ? Number(row.affectedCount) : 0, + renderable: row.renderable === true, + text + }; + // Left out when unset, so the model default (unknown) applies. + if (has('outcome')) { values.outcome = row.outcome; } + const stored = await sails.models.auditlog.create(values).fetch(); + if (req) { req.auditRecorded = true; } + return stored; + } catch (err) { + sails.log.warn(`audit: could not record ${type}: ${err && err.message ? err.message : err}`); + return null; + } + } +}; diff --git a/api/helpers/audit/subject.js b/api/helpers/audit/subject.js new file mode 100644 index 0000000..402b73a --- /dev/null +++ b/api/helpers/audit/subject.js @@ -0,0 +1,31 @@ +/** + * Note the row that a request writes in req.audit, for the audit trail + * (api/hooks/audit): the model identity, the row id, and the label (`name`, + * else `username`, else ''). + * + * Inputs: req, model (the identity), row (the written row, or null before it + * is known), id (used when there is no row; '' for none). + * + * Usage: sails.helpers.audit.subject(req, model, created) + */ +module.exports = { + friendlyName: 'Note the audit subject', + description: 'Set the subject fields of req.audit from a written row.', + sync: true, + inputs: { + req: {type: 'ref', required: true}, + model: {type: 'string', required: true}, + row: {type: 'ref', defaultsTo: null}, + id: {type: 'ref', defaultsTo: ''} + }, + exits: { + success: {description: 'req.audit is set.'} + }, + fn: function ({req, model, row, id}) { + req.audit = Object.assign({}, req.audit, { + subjectType: model, + subjectId: row ? row.id : id, + subjectLabel: row ? (row.name || row.username || '') : '' + }); + } +}; diff --git a/api/helpers/group/authorize.js b/api/helpers/group/authorize.js index 4bb36d5..b6656e9 100644 --- a/api/helpers/group/authorize.js +++ b/api/helpers/group/authorize.js @@ -17,7 +17,9 @@ * host's new `groups` value, already normalized * (Host.normalizeGroups); [] for a removal check * - * It returns undefined when the change is allowed, or the 403 message. + * It returns undefined when the change is allowed, or {message, permission}: + * the 403 message and the permission that refused (group.update, or + * group.create). The caller records the permission for the audit trail. * * Usage: let denied = await sails.helpers.group.authorize.with({permissions: req.user.permissions, names}) */ @@ -32,18 +34,18 @@ module.exports = { names: {type: 'ref', required: true, description: 'The names to add or to set.'} }, exits: { - success: {outputType: 'ref', description: 'undefined when allowed, else the 403 message.'} + success: {outputType: 'ref', description: 'undefined when allowed, else {message, permission}.'} }, fn: async function ({permissions, names}) { if (!_.get(permissions, 'stock.group.update')) { - return UPDATE_DENIED; + return {message: UPDATE_DENIED, permission: 'group.update'}; } if (_.get(permissions, 'stock.group.create')) { return undefined; } for (let name of names) { if (!(await sails.helpers.group.find.with({name}))) { - return CREATE_DENIED; + return {message: CREATE_DENIED, permission: 'group.create'}; } } return undefined; diff --git a/api/helpers/group/check-save.js b/api/helpers/group/check-save.js index 082184a..84b74c5 100644 --- a/api/helpers/group/check-save.js +++ b/api/helpers/group/check-save.js @@ -24,7 +24,7 @@ * * It returns {error: {status, message}} when the write must stop (400 for a * name that is not text, 409 for a taken name, 403 for a denied membership - * change), else {oldGroup}: the Group as read before a rename, for + * change, which also carries the refusing `permission`), else {oldGroup}: the Group as read before a rename, for * group/rename after the write. * * Usage: let check = await sails.helpers.group.checkSave.with({model, id, values, permissions: req.user.permissions}) @@ -92,7 +92,8 @@ module.exports = { } let denied = await sails.helpers.group.authorize.with({permissions, names}); if (denied) { - return {error: {status: 403, message: denied}}; + // The permission that refused goes with the message, for the audit trail. + return {error: {status: 403, message: denied.message, permission: denied.permission}}; } } return {}; diff --git a/api/hooks/audit/index.js b/api/hooks/audit/index.js new file mode 100644 index 0000000..47d3af2 --- /dev/null +++ b/api/hooks/audit/index.js @@ -0,0 +1,110 @@ +/** + * audit hook: the request seam of the audit trail + * (docs/adr/0003-audit-trail.md). + * + * It records the generic header of a request on the response's `finish` + * event, through sails.helpers.audit.record, unless the request already wrote + * its own row (req.auditRecorded: sign-in, sign-out, the agent token and + * enrollment decisions). + * + * Covered: + * - any request whose response status is 401 or 403 (a denial), and + * - any POST, PUT, PATCH or DELETE by an authenticated user (req.user). + * + * Not covered (no row): + * - an allowed GET, HEAD or OPTIONS (1.6 ADR 0021 Decision 12: an allowed + * read writes no row, a denied read does), + * - an allowed request to an action in READ_ACTIONS (reads that use POST), + * - a path under /agent/v1/ (the agent protocol records its own events), + * - the sign-in and sign-out actions (they record their own events), + * - an asset path, and /csrfToken. + * + * The controllers and policies describe the action by setting req.audit + * (permission, subjectType, subjectId, subjectLabel, affectedCount, and an + * outcome when the status says otherwise) before they answer. The seam reads + * it when the response finishes. A set req.audit.outcome wins over the status: + * a full-page save that fails answers 302, and that is not "allowed". + * + * Where it attaches: the correlationId middleware in config/http.js calls + * sails.hooks.audit.watch(req, res) for every request. That runs ahead of the + * router, so it also sees the answers that Sails gives before any hook route + * runs, such as a Sails CSRF refusal. + */ + +// The actions that only read but use POST (matched on req.options.action). An +// allowed request to one of them writes no row. A denied one still does. +const READ_ACTIONS = [ + 'general/datatable', + 'general/columns', + 'task-history', + // The pending hosts DataTable (a POST read, like general/datatable). + 'host/pending-datatable', + // Signs a JWT for the user and changes no record. + 'auth/token' +]; + +// These record their own events (api/controllers/auth/login.js, logout.js). +const OWN_EVENT_ACTIONS = ['auth/login', 'auth/logout']; + +const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; +// The agent protocol path (api/controllers/agent/enroll.js records its own events). +const AGENT_PREFIX = '/agent/v1/'; + +module.exports = function defineAuditHook(sails) { + // The row of a finished request, or undefined when it writes none. + const rowOf = (req, res) => { + const method = String(req.method || 'GET').toUpperCase(); + const path = req.path || ''; + const action = req.options && req.options.action; + if (path.startsWith(AGENT_PREFIX) || path === '/csrfToken') { return undefined; } + if (req.url && sails.LOOKS_LIKE_ASSET_RX.test(req.url)) { return undefined; } + if (OWN_EVENT_ACTIONS.includes(action)) { return undefined; } + + const status = res.statusCode; + const denied = status === 401 || status === 403; + if (!denied) { + if (!WRITE_METHODS.includes(method) || !req.user) { return undefined; } + if (READ_ACTIONS.includes(action)) { return undefined; } + } + + const audit = req.audit || {}; + const summary = `${method} ${path}`; + let outcome = 'failed'; + if (denied) { + outcome = 'denied'; + } else if (status >= 200 && status < 400) { + outcome = 'allowed'; + } + // A controller that knows better (a failed page save answers 302) says so. + if (audit.outcome) { outcome = audit.outcome; } + const text = outcome === 'failed' ? `HTTP ${status} ${summary}` : summary; + return { + type: path.startsWith('/api/') ? 'access.api' : 'access.page', + outcome, + text, + permission: audit.permission || '', + subjectType: audit.subjectType || '', + subjectId: audit.subjectId === undefined ? '' : audit.subjectId, + subjectLabel: audit.subjectLabel || '', + affectedCount: audit.affectedCount || 0 + }; + }; + + return { + // Record the request when its response finishes. Called once per request + // by the correlationId middleware (config/http.js). + watch: function (req, res) { + res.once('finish', async () => { + // The helper never throws. rowOf could, on a request shape that this + // code did not expect, and that must not stop the server. + try { + if (req.auditRecorded) { return; } + const row = rowOf(req, res); + if (row) { await sails.helpers.audit.record.with({req, row}); } + } catch (err) { + sails.log.warn(`audit: could not read the request: ${err && err.message ? err.message : err}`); + } + }); + } + }; +}; diff --git a/api/models/AuditLog.js b/api/models/AuditLog.js new file mode 100644 index 0000000..11076b6 --- /dev/null +++ b/api/models/AuditLog.js @@ -0,0 +1,48 @@ +/* + * AuditLog.js + * + * @description :: The audit trail: one append-only row per audited action + * (docs/adr/0003-audit-trail.md). A port of the FOG 1.6 + * `auditLog` table (ADR 0021), without the impersonation + * columns. `createdAt` is the global auto timestamp. + * + * Only api/helpers/audit/record.js writes a row. The model has no + * `stock.auditlog` entry in config/permissions.js, so the generic + * /api/v1/:model routes refuse it, and nothing in the app updates or + * destroys a row. The indexes are made at bootstrap by + * api/helpers/audit/ensure-indexes.js. + * + * The defaults matter: with `schema: true` an unset attribute is stored as + * its default, and `outcome` defaults to `unknown` so an incomplete row never + * claims `allowed` (1.6 does the same). + */ +module.exports = { + attributes: { + // The actor's user name; (system) for a write no person made; the + // attempted user name on a failed sign-in. + createdBy: {type: 'string', defaultsTo: ''}, + ip: {type: 'string', defaultsTo: ''}, + // How the REQUEST authenticated: session, apitoken, jwt, local, anonymous, agent. + authSource: {type: 'string', defaultsTo: ''}, + // The dot-namespaced event type, e.g. auth.login or access.api. + type: {type: 'string', defaultsTo: ''}, + subjectType: {type: 'string', defaultsTo: ''}, + subjectId: {type: 'string', defaultsTo: ''}, + // The subject's name at the time, so it survives a delete. + subjectLabel: {type: 'string', defaultsTo: ''}, + // The permission consulted. '' means no authorization ran. + permission: {type: 'string', defaultsTo: ''}, + outcome: { + type: 'string', + isIn: ['unknown', 'allowed', 'denied', 'failed', 'partial'], + defaultsTo: 'unknown' + }, + correlationId: {type: 'string', defaultsTo: ''}, + affectedCount: {type: 'number', defaultsTo: 0}, + // 1.6's activity-feed flag. + renderable: {type: 'boolean', defaultsTo: false}, + // Untranslated machine detail, cut to 4096 characters by the helper. + // Never a password, token or key. + text: {type: 'string', defaultsTo: ''} + } +}; diff --git a/api/policies/create.js b/api/policies/create.js index cfe939b..6c500ff 100644 --- a/api/policies/create.js +++ b/api/policies/create.js @@ -1,7 +1,7 @@ module.exports = async function(req, res, next) { let model = req.allParams(); model = model.model; - if (_.get(req, 'user.permissions.stock.'+model+'.create')) { + if (sails.helpers.audit.can(req, model + '.create')) { return next(); } res.forbidden(); diff --git a/api/policies/destroy.js b/api/policies/destroy.js index eb98251..c63087c 100644 --- a/api/policies/destroy.js +++ b/api/policies/destroy.js @@ -1,7 +1,7 @@ module.exports = async function(req, res, next) { let model = req.allParams(); model = model.model; - if (_.get(req, 'user.permissions.stock.'+model+'.destroy')) { + if (sails.helpers.audit.can(req, model + '.destroy')) { return next(); } res.forbidden(); diff --git a/api/policies/image/capture.js b/api/policies/image/capture.js index 7bfc334..9b7d006 100644 --- a/api/policies/image/capture.js +++ b/api/policies/image/capture.js @@ -1,6 +1,6 @@ module.exports = async function(req, res, next) { 'use strict'; - if (_.get(req, 'user.permissions.stock.image.capture')) { + if (sails.helpers.audit.can(req, 'image.capture')) { return next(); } res.forbidden(); diff --git a/api/policies/image/deploy.js b/api/policies/image/deploy.js index dfe97c3..11a7d4c 100644 --- a/api/policies/image/deploy.js +++ b/api/policies/image/deploy.js @@ -1,6 +1,6 @@ module.exports = async function(req, res, next) { 'use strict'; - if (_.get(req, 'user.permissions.stock.image.deploy')) { + if (sails.helpers.audit.can(req, 'image.deploy')) { return next(); } res.forbidden(); diff --git a/api/policies/isLoggedIn.js b/api/policies/isLoggedIn.js index 3834572..a33541d 100644 --- a/api/policies/isLoggedIn.js +++ b/api/policies/isLoggedIn.js @@ -16,7 +16,12 @@ module.exports = async (req, res, next) => { req.authVia = 'apitoken'; return req.login(user.toJSON(), { session: false }, (err) => err ? next(err) : next()); } - // Unknown token: fall through unauthenticated -> isAuthenticated will 403. + // Unknown token: record it (never the token value), then fall through + // unauthenticated -> isAuthenticated will 403. + await sails.helpers.audit.record.with({req, row: { + type: 'auth.token.rejected', outcome: 'denied', subjectType: 'system', + authSource: 'apitoken', createdBy: '' + }}); } // 2) Session / JWT (cookie or bearer) auth -- existing behaviour. @@ -24,7 +29,9 @@ module.exports = async (req, res, next) => { await passport.authenticate(sails.config.globals.jwtAuthMechanisms, { session: false }, async (err, user) => { if (err) return next(err); if (!user) return next(); - req.authVia = 'session'; + // The JWT strategy (cookie or header). A plain browser session sets no + // authVia: the audit trail reads that as 'session'. + req.authVia = 'jwt'; user = user.toJSON(); // keepSessionInfo: Passport >=0.6 regenerates the session on req.login // (fixation protection). Since this policy re-logs-in on every diff --git a/api/policies/read.js b/api/policies/read.js index 02f5a74..bc6d1a8 100644 --- a/api/policies/read.js +++ b/api/policies/read.js @@ -1,7 +1,7 @@ module.exports = async function(req, res, next) { let model = req.allParams(); model = model.model; - if (_.get(req, 'user.permissions.stock.'+model+'.read')) { + if (sails.helpers.audit.can(req, model + '.read')) { return next(); } res.forbidden(); diff --git a/api/policies/role/assign.js b/api/policies/role/assign.js index 405c2b3..bb58671 100644 --- a/api/policies/role/assign.js +++ b/api/policies/role/assign.js @@ -1,6 +1,6 @@ module.exports = async function(req, res, next) { 'use strict'; - if (_.get(req, 'user.permissions.stock.role.assign')) { + if (sails.helpers.audit.can(req, 'role.assign')) { return next(); } res.forbidden(); diff --git a/api/policies/role/unassign.js b/api/policies/role/unassign.js index aa99406..60b376f 100644 --- a/api/policies/role/unassign.js +++ b/api/policies/role/unassign.js @@ -1,6 +1,6 @@ module.exports = async function(req, res, next) { 'use strict'; - if (_.get(req, 'user.permissions.stock.role.unassign')) { + if (sails.helpers.audit.can(req, 'role.unassign')) { return next(); } res.forbidden(); diff --git a/api/policies/update.js b/api/policies/update.js index 7eed71a..d59a93d 100644 --- a/api/policies/update.js +++ b/api/policies/update.js @@ -1,7 +1,7 @@ module.exports = async function(req, res, next) { let model = req.allParams(); model = model.model; - if (_.get(req, 'user.permissions.stock.'+model+'.update')) { + if (sails.helpers.audit.can(req, model + '.update')) { return next(); } res.forbidden(); diff --git a/config/bootstrap.js b/config/bootstrap.js index e31b694..1679135 100644 --- a/config/bootstrap.js +++ b/config/bootstrap.js @@ -24,6 +24,10 @@ module.exports.bootstrap = async function() { // agentId), for the same reason. await sails.helpers.agent.ensureIndexes(); + // The audit trail indexes (createdAt, createdBy, correlationId, outcome and + // the subject), for the same reason. + await sails.helpers.audit.ensureIndexes(); + // By convention, this is a good place to set up fake data during development. // // For example: diff --git a/config/http.js b/config/http.js index 9de9fff..31c5ce7 100644 --- a/config/http.js +++ b/config/http.js @@ -1,5 +1,18 @@ +const crypto = require('crypto'); const passport = require('passport'); +// One id per HTTP request, for the audit trail (api/helpers/audit/record.js). +// It lives on the request object, not in module state, because Node serves +// requests concurrently. It is not sent to the client. +// This middleware also starts the audit seam for the request. It runs ahead of +// the router, so the seam sees every response, a Sails CSRF refusal included +// (api/hooks/audit). The hook is absent only when it is disabled. +const correlationId = (req, res, next) => { + req.correlationId = crypto.randomBytes(16).toString('hex'); + if (sails.hooks.audit) { sails.hooks.audit.watch(req, res); } + return next(); +}; + // Passport's session() middleware requires an active session. Sails skips // session setup for asset-looking requests (see the commented isSessionDisabled // note in config/session.js / LOOKS_LIKE_ASSET_RX), so running passport on those @@ -10,10 +23,17 @@ const onlyWithSession = (middleware) => (req, res, next) => req.session ? middleware(req, res, next) : next(); module.exports.http = { + // req.ip is the socket address unless Express trusts the proxy. Audit rows + // and agent enrollment rows record req.ip. Behind a reverse proxy, set + // http: {trustProxy: true} in config/local.js (docs/install.md) so they + // record the client address. npm run setup keeps that setting. + trustProxy: false, middleware: { + correlationId, passportInit: onlyWithSession(passport.initialize()), passportSession: onlyWithSession(passport.session()), order: [ + 'correlationId', 'cookieParser', 'session', 'passportInit', diff --git a/docs/adr/0003-audit-trail.md b/docs/adr/0003-audit-trail.md new file mode 100644 index 0000000..97cd45b --- /dev/null +++ b/docs/adr/0003-audit-trail.md @@ -0,0 +1,111 @@ +# 3. The audit trail: one append-only header per audited action + +- Status: **Accepted** +- Date: 2026-11-10 + +## Context + +- fog-node recorded nothing about a sign-in, a failed sign-in, a denied request, + an administrator's write or an agent approval. +- FOG 1.6 has an audit trail (fogproject ADR 0021). This ADR ports the parts of it + that the agent mTLS work needs first. +- ADR 0021 numbers its decisions. This ADR names them as "1.6 decision N". + +## Decision + +Ported from 1.6, with the same meaning (1.6 decisions 1, 3, 4, 5 and the agent +part of 7): + +- `AuditLog` holds one row per audited action (1.6 decision 1). It is a + projection for the activity feed through `renderable` and `type`. A later brief + adds the change table `AuditChange`. +- A machine-originated write gets a row with a synthetic actor, `(system)`, and + an empty `permission`, which means that no authorization ran (1.6 decision 4). + Only the fog-agent enrollment route does this so far. The imaging boot and task + paths come later. +- `outcome` defaults to `unknown`, so an incomplete row never claims `allowed`. + The other values are `allowed`, `denied`, `failed` and `partial` (1.6 + decision 11). +- An allowed read writes no row. A denied read writes one (1.6 decision 12). +- Nothing writes, changes or deletes a row except `api/helpers/audit/record.js`. + The model has no `stock.auditlog` entry in `config/permissions.js`, so the + generic `/api/v1/:model` routes refuse it (1.6 decision 8, the route part). The + least-privilege database grant is a deployment choice, as in 1.6. +- An audit write never breaks the request. The helper catches every error, logs + one warning that names the `type`, and returns `null`. +- Nothing is backfilled. +- The model keeps the 1.6 columns and leaves out the impersonation columns + `actedAs` and `spanID`. The indexes are `createdAt`, `createdBy`, + `correlationId`, `outcome` and `{subjectType, subjectId}`. None is unique: a + unique key would merge two identical events. + +The events: + +| Event | Where | +|---|---| +| `auth.login`, `auth.login.failed`, `auth.logout`, `auth.token.rejected` | `auth/login`, `auth/logout`, `policies/isLoggedIn` | +| `access.api`, `access.page` | the request seam, `api/hooks/audit` | +| `agent.enroll`, `agent.token` | the enrollment route, the admin decision and the token routes, with the 1.6 texts | + +A failed sign-in stores the attempted name and no reason, to avoid user +enumeration (1.6 stores none either). + +## Differences from 1.6 + +- The correlation id is set per request. A middleware in `config/http.js` puts + `req.correlationId` on the request object. 1.6 keeps it in static state, which + is per request in PHP. Node serves requests concurrently, so module state would + mix them. +- The seam is the response `finish` event, not the authorization gate. fog-node + has no one gate: eight policies and about twenty inline checks answer 403. + `api/hooks/audit` sees the status of every response it covers. The policies and + the controllers describe the action through `req.audit` (`permission`, + `subjectType`, `subjectId`, `subjectLabel`, `affectedCount`) before they answer. + The hook writes the row after the response, so a test that expects a row polls + for it. +- A row covers a request that gets 401 or 403, and a `POST`, `PUT`, `PATCH` or + `DELETE` by an authenticated user. The hook skips the allowed reads that use + `POST` (listed in `READ_ACTIONS` in the hook), the `/agent/v1/` paths, the + sign-in and sign-out routes (they record their own events), assets and + `/csrfToken`. A request that already wrote its own row (`req.auditRecorded`) + gets no second row. +- Ids are strings (`subjectId`), because Mongo ids are strings. 1.6 uses integers. +- `authSource` names how the request authenticated: `session`, `apitoken`, `jwt`, + `local` (the sign-in itself), `anonymous` or `agent`. `isLoggedIn` sets + `req.authVia` to `jwt` for the JWT strategy. A plain browser session sets + nothing, and the helper reads that as `session`. +- No row holds a before and after value yet. +- `req.ip` is the socket address unless Express trusts the proxy. The default is + `trustProxy: false` in `config/http.js`. Behind a reverse proxy, set + `http: {trustProxy: true}` in `config/local.js` (see `docs/install.md`). + +## Later + +- Change rows with redaction (1.6 decisions 5 to 7): `AuditChange`, the + before and after values, and the redaction of a credential field. +- The `audit.view` and `audit.manage` permissions, and the Audit Log page. +- Retention, and the rule that shrinking the trail writes a row first (1.6 + decision 10). +- The machine paths other than the agent enrollment route. + +## Consequences + +- A new write path needs no audit code if it goes through a route that the hook + covers. A controller that knows more (the subject, the count) sets `req.audit`. + A controller that records its own event calls + `sails.helpers.audit.record.with({req, row})`. +- The seam sees every response, a Sails CSRF refusal of a page form included, + because the `correlationId` middleware in `config/http.js` attaches it. That + middleware runs ahead of the router. +- A full-page save that fails answers 302. `general/save` sets + `req.audit.outcome` (`denied` for a refused group change, `failed` for any other + error), and the seam uses it in place of the status. +- At the end of a run, the test suite (`test/lifecycle.test.js`) deletes the + `AuditLog` rows that meet both conditions: + - `createdAt` is at or after the start of the run. + - `createdBy` is `mochatestadmin` or starts with one of `PREFIXES`, or + `subjectLabel` starts with one of `PREFIXES`. + The one test that checks the `agent-` fallback host name also deletes + the rows of the hosts it made, by `subjectId`. A dev server that shares the + database keeps all its other rows. The rows of anonymous denied requests and the + `auth.token.rejected` rows of the suite have no name, so they stay. diff --git a/docs/install.md b/docs/install.md index dbadd4d..f37aff6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -213,3 +213,9 @@ in front: nginx, Caddy, Apache, or IIS with URL Rewrite. Set the proxy to pass WebSocket upgrades through, for the socket.io connection (see "Browser URLs"). If you do this, give the installer the proxy's URL (for example `https://fog.example.com`), not `:1337`. + +fog-node records the client address (`req.ip`) in the audit log and in the agent +enrollment rows. By default it records the address of the connection, which is the +proxy's address behind a reverse proxy. To record the client address, set +`http: {trustProxy: true}` in `config/local.js`, and make the proxy send +`X-Forwarded-For`. `npm run setup` keeps this setting when you run it again. diff --git a/test/integration/agent-admin.test.js b/test/integration/agent-admin.test.js index dd08663..3f7e492 100644 --- a/test/integration/agent-admin.test.js +++ b/test/integration/agent-admin.test.js @@ -39,7 +39,9 @@ describe('Agent admin::', function () { agent_version: '1.0.0', os: 'linux', arch: 'amd64', - hostname: '', + // A prefixed name, so the audit rows of the test are removable + // (test/lifecycle.test.js). + hostname: uniq(), identity: {system_uuid: '', system_serial: '', board_serial: '', chassis_asset: '', macs}, csr_pem: c.pem }, extra); diff --git a/test/integration/agent-enroll.test.js b/test/integration/agent-enroll.test.js index 38e0da4..ef7a9b5 100644 --- a/test/integration/agent-enroll.test.js +++ b/test/integration/agent-enroll.test.js @@ -50,7 +50,9 @@ describe('Agent enroll::', function () { agent_version: 'v0.0.1', os: 'linux', arch: 'amd64', - hostname: '', + // A prefixed name, so the audit rows of the test are removable + // (test/lifecycle.test.js). The fallback name test passes its own. + hostname: uniq(), identity: {system_uuid: '', system_serial: '', board_serial: '', chassis_asset: '', macs: []}, csr_pem: c.pem }, extra); @@ -188,11 +190,19 @@ describe('Agent enroll::', function () { }); it('unknown-host: a hostname that fails the name rule or is over 15 characters becomes agent-', async () => { - for (const hostname of ['bad name!', 'x'.repeat(16), '']) { - const c = await newCsr(); - await enroll(c, {hostname, identity: identity({macs: [mac()]})}).expect(202); - const host = await hostOf((await rowOf(c)).host); - assert.strictEqual(host.name, 'agent-' + c.fingerprint.slice(0, 8), `hostname ${JSON.stringify(hostname)}`); + const hostIds = []; + try { + for (const hostname of ['bad name!', 'x'.repeat(16), '']) { + const c = await newCsr(); + await enroll(c, {hostname, identity: identity({macs: [mac()]})}).expect(202); + const host = await hostOf((await rowOf(c)).host); + hostIds.push(host.id); + assert.strictEqual(host.name, 'agent-' + c.fingerprint.slice(0, 8), `hostname ${JSON.stringify(hostname)}`); + } + } finally { + // The fallback name has no suite prefix, so remove the audit rows of + // these hosts by their id (the lifecycle cleanup cannot attribute them). + await AuditLog.destroy({subjectType: 'host', subjectId: hostIds}); } }); diff --git a/test/integration/audit.test.js b/test/integration/audit.test.js new file mode 100644 index 0000000..c6ed8ab --- /dev/null +++ b/test/integration/audit.test.js @@ -0,0 +1,693 @@ +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const supertest = require('supertest'); +const agentCsr = require('../support/agent-csr'); +const support = require('../support/pki'); +const {PREFIXES} = require('../lifecycle.test'); + +// The audit trail core (docs/adr/0003-audit-trail.md): the AuditLog model, the +// record helper, the request seam (api/hooks/audit), the sign-in events and the +// fog-agent enrollment and token events. +// +// The seam writes after the response finishes, so a test that expects a row +// polls for it. A test that expects NO row makes its request, then a sentinel +// request that does write a row (a denied GET by the same actor), waits for the +// sentinel row, and only then asserts the absence. Every name a test creates +// starts with a lifecycle PREFIXES entry. The correlation id is not exposed to +// a client, so no test filters on it. +describe('Audit trail::', function () { + this.timeout(120000); + + support.useFixture(); + const app = () => sails.hooks.http.app; + const HTML = ['Accept', 'text/html']; + const AJAX = ['X-Requested-With', 'XMLHttpRequest']; + const RUN = crypto.randomBytes(3).toString('hex'); + // Users, roles and attempted sign-in names. + const PREFIX = PREFIXES[0] + '-aud' + RUN; + // Hosts, tokens and agent hostnames: a host name is at most 15 characters. + const SHORT = PREFIXES[1] + RUN; + const PASSWORD = 'mocha-audit-pw'; + let seq = 0; + const uniq = () => PREFIX + '-' + (seq++); + const short = () => SHORT + (seq++); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const mac = () => '02:' + crypto.randomBytes(5).toString('hex').match(/../g).join(':'); + const bare = (m) => m.replace(/[:-]/g, '').toLowerCase(); + const LOOPBACK = ['127.0.0.1', '::ffff:127.0.0.1', '::1']; + + // Poll every 25 ms, up to 2 s, for at least `count` rows at or after `start`. + async function waitFor(where, start, count = 1) { + const deadline = Date.now() + 2000; + for (;;) { + const found = await AuditLog.find(Object.assign({}, where, {createdAt: {'>=': start}})).sort('createdAt ASC'); + if (found.length >= count) { return found; } + if (Date.now() > deadline) { assert.fail(`no audit row for ${JSON.stringify(where)} within 2 s`); } + await sleep(25); + } + } + const rowsOf = (where, start) => AuditLog.find(Object.assign({}, where, {createdAt: {'>=': start}})).sort('createdAt ASC'); + + // Users. `permissions` is the role's {stock: {...}}; isAdmin gives all. + const roleNames = []; + async function mkUser(label, {isAdmin = false, permissions = {}} = {}) { + const name = `${PREFIX}-${label}`; + roleNames.push(name); + const role = await Role.create({name, isAdmin, permissions}).fetch(); + const user = await User.create({username: name, password: PASSWORD, email: `${name}@example.com`, roles: [role.id]}).fetch(); + return {username: name, id: user.id}; + } + + // Signs in as a browser does: a CSRF token for the login form, then the form + // POST. The agent keeps the session cookie. + async function signIn(username) { + const agent = supertest.agent(app()); + const {body} = await agent.get('/csrfToken').expect(200); + await agent.post('/login').type('form').set(...HTML) + .send({username, password: PASSWORD, _csrf: body._csrf}) + .expect(302).expect('Location', '/'); + return {kind: 'session', username, agent}; + } + // A JSON sign-in: the answer holds a JWT. + async function jwtOf(username) { + const res = await supertest(app()).post('/api/v1/auth/login').send({username, password: PASSWORD}).expect(200); + return {kind: 'jwt', username, token: res.body.token}; + } + // An API token, set on the user as POST /users/:id/api-token does. + async function tokenOf(user) { + const token = 'fpat_' + crypto.randomBytes(24).toString('hex'); + await User.updateOne({id: user.id}).set({apiTokenHash: crypto.createHash('sha256').update(token).digest('hex')}); + return {kind: 'apitoken', username: user.username, token}; + } + // A request by an actor, with its credential. A cookie actor sends the same + // origin header that the UI sends. + const as = (actor) => { + const verb = (method) => (url) => { + let req; + if (actor.kind === 'session') { + req = actor.agent[method](url); + } else { + req = supertest(app())[method](url); + req = actor.kind === 'jwt' ? req.set('Authorization', actor.token) : req.set('Authorization', `Bearer ${actor.token}`); + } + return req.set(...AJAX); + }; + return {get: verb('get'), post: verb('post'), put: verb('put'), del: verb('delete')}; + }; + // A denied GET by the actor on a model that no one can read: a row for sure. + // Returns the row once it is stored. It proves the earlier writes landed. + async function sentinel(actor, start) { + const model = `${PREFIX}-sentinel-${seq++}`; + await as(actor).get(`/api/v1/${model}`).expect(403); + const [row] = await waitFor({permission: `${model}.read`}, start); + return row; + } + + let admin; + let adminSession; + let limitedUser; + let limitedSession; + const hostIds = []; + const mkHost = async (fields = {}) => { + const host = await sails.models.host.create(Object.assign({name: short(), pending: false}, fields)).fetch(); + hostIds.push(host.id); + return host; + }; + + async function cleanup() { + await sails.models.host.destroy({name: {startsWith: SHORT}}); + await sails.models.host.destroy({id: hostIds}); + await sails.models.image.destroy({name: {startsWith: PREFIX}}); + await sails.models.group.destroy({name: {startsWith: PREFIX}}); + await sails.models.agentenrolltoken.destroy({name: {startsWith: SHORT}}); + await User.destroy({username: {startsWith: PREFIX}}); + await Role.destroy({name: {startsWith: PREFIX}}); + } + + before(async () => { + await cleanup(); + admin = await mkUser('admin', {isAdmin: true}); + limitedUser = await mkUser('limited', {permissions: {stock: {host: {read: true, update: true}}}}); + adminSession = await signIn(admin.username); + limitedSession = await signIn(limitedUser.username); + }); + after(cleanup); + + describe('the model and the helper', () => { + it('stores a row with the defaults: outcome unknown, a string id, text cut to 4096', async () => { + const start = Date.now(); + const label = uniq(); + const row = await sails.helpers.audit.record.with({req: null, row: { + type: 'test.helper', subjectType: 'host', subjectId: 12345, subjectLabel: label, text: 'x'.repeat(5000) + }}); + assert.strictEqual(row.outcome, 'unknown', 'an incomplete row never claims allowed'); + assert.strictEqual(row.subjectId, '12345'); + assert.strictEqual(row.text.length, 4096); + assert.strictEqual(row.createdBy, '(system)'); + assert.strictEqual(row.ip, ''); + assert.strictEqual(row.correlationId, ''); + assert.strictEqual(row.authSource, 'anonymous'); + assert.strictEqual(row.affectedCount, 0); + assert.strictEqual(row.renderable, false); + assert.strictEqual(row.permission, ''); + const [stored] = await waitFor({subjectLabel: label}, start); + assert.strictEqual(stored.id, row.id); + }); + + it('refuses an outcome that is not one of the five', async () => { + const row = await sails.helpers.audit.record.with({req: null, row: {type: 'test.helper', outcome: 'maybe', subjectLabel: uniq()}}); + assert.strictEqual(row, null); + }); + + it('never throws: a failing write returns null and logs one warning', async () => { + const create = sails.models.auditlog.create; + const warn = sails.log.warn; + const warnings = []; + sails.models.auditlog.create = () => { throw new Error('boom'); }; + sails.log.warn = (...args) => { warnings.push(args.join(' ')); }; + let row; + try { + row = await sails.helpers.audit.record.with({req: null, row: {type: 'test.helper'}}); + } finally { + sails.models.auditlog.create = create; + sails.log.warn = warn; + } + assert.strictEqual(row, null); + assert.strictEqual(warnings.length, 1); + assert.ok(warnings[0].includes('test.helper'), warnings[0]); + }); + + it('creates the indexes at bootstrap, and none is unique', async () => { + const model = sails.models.auditlog; + const indexes = await sails.getDatastore(model.datastore).manager.collection(model.tableName).indexes(); + const keys = indexes.map((i) => Object.keys(i.key).join('+')); + for (const key of ['createdAt', 'createdBy', 'correlationId', 'outcome', 'subjectType+subjectId']) { + assert.ok(keys.includes(key), `no index on ${key}: ${keys}`); + } + assert.ok(!indexes.some((i) => i.unique && i.name !== '_id_'), 'an audit index is unique'); + }); + + it('gives each request its own 32-hex correlation id', async () => { + const start = Date.now(); + const a = await sentinel(limitedSession, start); + const b = await sentinel(limitedSession, start); + assert.match(a.correlationId, /^[0-9a-f]{32}$/); + assert.match(b.correlationId, /^[0-9a-f]{32}$/); + assert.notStrictEqual(a.correlationId, b.correlationId); + }); + + it('has no route that writes, changes or deletes a row, and no code that does', async () => { + const start = Date.now(); + const before = await AuditLog.count(); + for (const method of ['post', 'put', 'del']) { + const url = method === 'post' ? '/api/v1/auditlog' : '/api/v1/auditlog/abc'; + await as(adminSession)[method](url).send({text: 'x'}).expect(403); + } + await as(adminSession).get('/api/v1/auditlog').expect(403); + await sentinel(adminSession, start); + // Only audit rows of the refusals themselves were added. + const added = (await AuditLog.count()) - before; + assert.ok(added >= 5, `the refusals and the sentinel write rows (${added})`); + assert.strictEqual(await AuditLog.count({permission: 'auditlog.update', text: 'x'}), 0); + + // Nothing in the app updates or destroys an AuditLog row. + const root = path.join(__dirname, '..', '..'); + const hits = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(file); } else if (entry.name.endsWith('.js') && /auditlog\s*\.\s*(update|destroy|archive)/i.test(fs.readFileSync(file, 'utf8'))) { hits.push(file); } + } + }; + ['api', 'config', 'plugins', 'tools', 'migrations'].forEach((d) => fs.existsSync(path.join(root, d)) && walk(path.join(root, d))); + assert.deepStrictEqual(hits, []); + }); + }); + + describe('authentication events', () => { + it('writes one auth.login row for a correct sign-in through POST /api/v1/auth/login', async () => { + const user = await mkUser('login'); + const start = Date.now(); + await supertest(app()).post('/api/v1/auth/login').send({username: user.username, password: PASSWORD}).expect(200); + const [row] = await waitFor({type: 'auth.login', createdBy: user.username}, start); + assert.strictEqual(row.outcome, 'allowed'); + assert.strictEqual(row.authSource, 'local'); + assert.strictEqual(row.subjectType, 'user'); + assert.strictEqual(row.subjectId, user.id); + assert.strictEqual(row.subjectLabel, user.username); + assert.ok(LOOPBACK.includes(row.ip), row.ip); + assert.match(row.correlationId, /^[0-9a-f]{32}$/); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: user.username}, start)).length, 1, 'the seam added no second row'); + }); + + it('writes one auth.login.failed row each for a wrong password and an unknown name', async () => { + const user = await mkUser('wrongpw'); + const unknown = uniq() + '-nobody'; + const start = Date.now(); + await supertest(app()).post('/api/v1/auth/login').send({username: user.username, password: 'wrong'}).expect(401); + await supertest(app()).post('/api/v1/auth/login').send({username: ` ${unknown} `, password: 'wrong'}).expect(401); + const [bad] = await waitFor({type: 'auth.login.failed', createdBy: user.username}, start); + const [none] = await waitFor({type: 'auth.login.failed', createdBy: unknown}, start); + for (const [row, name] of [[bad, user.username], [none, unknown]]) { + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.text, '', 'no reason is stored'); + assert.strictEqual(row.subjectType, 'user'); + assert.strictEqual(row.subjectId, ''); + assert.strictEqual(row.subjectLabel, name); + assert.strictEqual(row.authSource, 'local'); + } + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: user.username}, start)).length, 1); + assert.strictEqual((await rowsOf({createdBy: unknown}, start)).length, 1); + }); + + it('cuts the attempted name to 191 characters', async () => { + const long = PREFIX + 'n'.repeat(300); + const start = Date.now(); + await supertest(app()).post('/api/v1/auth/login').send({username: long, password: 'wrong'}).expect(401); + const [row] = await waitFor({type: 'auth.login.failed', createdBy: long.slice(0, 191)}, start); + assert.strictEqual(row.createdBy.length, 191); + }); + + it('writes auth.logout naming the user, before the user is gone', async () => { + const user = await mkUser('logout'); + const session = await signIn(user.username); + const start = Date.now(); + await session.agent.post('/api/v1/auth/logout').set(...HTML).expect(302); + const [row] = await waitFor({type: 'auth.logout', createdBy: user.username}, start); + assert.strictEqual(row.outcome, 'allowed'); + assert.strictEqual(row.subjectType, 'user'); + assert.strictEqual(row.subjectId, user.id); + assert.strictEqual(row.subjectLabel, user.username); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: user.username}, start)).length, 1, 'the seam added no second row'); + }); + + it('writes auth.token.rejected for an unknown fpat_ token, and never stores the token', async () => { + const secret = 'fpat_' + crypto.randomBytes(24).toString('hex'); + const start = Date.now(); + await supertest(app()).get('/api/v1/user/me').set('Authorization', `Bearer ${secret}`).expect(403); + const [row] = await waitFor({type: 'auth.token.rejected'}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.subjectType, 'system'); + assert.strictEqual(row.authSource, 'apitoken'); + assert.strictEqual(row.createdBy, ''); + await sentinel(adminSession, start); + const all = await rowsOf({}, start); + assert.ok(!JSON.stringify(all).includes(secret), 'a row holds the token text'); + assert.ok(!JSON.stringify(all).includes(crypto.createHash('sha256').update(secret).digest('hex')), 'a row holds the token hash'); + }); + + it('keeps a sign-in working when the audit write fails, and logs one warning', async () => { + const user = await mkUser('stub'); + const create = sails.models.auditlog.create; + const warn = sails.log.warn; + const warnings = []; + sails.models.auditlog.create = () => { throw new Error('boom'); }; + sails.log.warn = (...args) => { warnings.push(args.join(' ')); }; + let res; + try { + res = await supertest(app()).post('/api/v1/auth/login').send({username: user.username, password: PASSWORD}); + } finally { + sails.models.auditlog.create = create; + sails.log.warn = warn; + } + assert.strictEqual(res.status, 200); + assert.ok(res.body.token); + assert.strictEqual(warnings.filter((w) => w.includes('auth.login')).length, 1, warnings.join('\n')); + }); + }); + + describe('the request seam', () => { + it('records a denied write: 403, access.api, denied, the permission that refused', async () => { + const host = await mkHost(); + const start = Date.now(); + // limitedUser holds host.update, so use a user with host.read only. + const reader = await mkUser('reader', {permissions: {stock: {host: {read: true}}}}); + const actor = await signIn(reader.username); + await as(actor).put(`/api/v1/host/${host.id}`).send({description: 'x'}).expect(403); + const [row] = await waitFor({type: 'access.api', createdBy: reader.username, permission: 'host.update'}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.text, `PUT /api/v1/host/${host.id}`); + assert.strictEqual(row.authSource, 'session'); + await sentinel(actor, start); + assert.strictEqual((await rowsOf({createdBy: reader.username, type: 'access.api', permission: 'host.update'}, start)).length, 1); + }); + + it('records an allowed admin PUT of a host with the subject, and a browser session as authSource session', async () => { + const host = await mkHost(); + const start = Date.now(); + await as(adminSession).put(`/api/v1/host/${host.id}`).send({description: 'audited'}).expect(200); + const [row] = await waitFor({type: 'access.api', createdBy: admin.username, subjectId: host.id}, start); + assert.strictEqual(row.outcome, 'allowed'); + assert.strictEqual(row.subjectType, 'host'); + assert.strictEqual(row.subjectLabel, host.name); + assert.strictEqual(row.permission, 'host.update'); + assert.strictEqual(row.authSource, 'session'); + assert.strictEqual(row.text, `PUT /api/v1/host/${host.id}`); + assert.match(row.correlationId, /^[0-9a-f]{32}$/); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: admin.username, subjectId: host.id}, start)).length, 1); + }); + + it('records the authSource of a JWT write as jwt and of an fpat_ write as apitoken', async () => { + const jwtUser = await mkUser('jwt', {isAdmin: true}); + const jwt = await jwtOf(jwtUser.username); + const tokUser = await mkUser('tok', {isAdmin: true}); + const tok = await tokenOf(tokUser); + for (const [actor, source] of [[jwt, 'jwt'], [tok, 'apitoken']]) { + const host = await mkHost(); + const start = Date.now(); + await as(actor).put(`/api/v1/host/${host.id}`).send({description: source}).expect(200); + const [row] = await waitFor({type: 'access.api', createdBy: actor.username, subjectId: host.id}, start); + assert.strictEqual(row.authSource, source); + assert.strictEqual(row.outcome, 'allowed'); + } + }); + + it('records one row with affectedCount 2 for an admin DELETE of two images', async () => { + const names = [uniq(), uniq()]; + const images = []; + for (const name of names) { images.push(await sails.models.image.create({name}).fetch()); } + const start = Date.now(); + await as(adminSession).del('/api/v1/image').send({id: images.map((i) => i.id)}).expect(200); + const [row] = await waitFor({type: 'access.api', createdBy: admin.username, subjectType: 'image'}, start); + assert.strictEqual(row.affectedCount, 2); + assert.strictEqual(row.subjectId, ''); + assert.strictEqual(row.permission, 'image.destroy'); + assert.strictEqual(row.outcome, 'allowed'); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: admin.username, subjectType: 'image'}, start)).length, 1); + }); + + it('records a created row with its id and name, and a deleted one with its name', async () => { + const name = uniq(); + const start = Date.now(); + const created = await as(adminSession).post('/api/v1/group').send({name}).expect(200); + const [made] = await waitFor({type: 'access.api', createdBy: admin.username, subjectType: 'group', subjectLabel: name}, start); + assert.strictEqual(made.subjectId, created.body.id); + assert.strictEqual(made.permission, 'group.create'); + await as(adminSession).del(`/api/v1/group/${created.body.id}`).expect(200); + const gone = await waitFor({type: 'access.api', createdBy: admin.username, subjectType: 'group', permission: 'group.destroy'}, start); + assert.strictEqual(gone[0].subjectLabel, name); + assert.strictEqual(gone[0].subjectId, created.body.id); + }); + + it('records no row for an allowed GET, and one for a denied GET', async () => { + const start = Date.now(); + await as(adminSession).get('/api/v1/host').expect(200); + await as(adminSession).get('/hosts').set(...HTML).expect(200); + const denied = await sentinel(adminSession, start); + assert.strictEqual(denied.outcome, 'denied'); + assert.strictEqual(denied.type, 'access.api'); + assert.strictEqual((await rowsOf({createdBy: admin.username, text: 'GET /api/v1/host'}, start)).length, 0); + assert.strictEqual((await rowsOf({createdBy: admin.username, text: 'GET /hosts'}, start)).length, 0); + }); + + it('records a denied page request as access.page', async () => { + const user = await mkUser('page'); + const actor = await signIn(user.username); + const start = Date.now(); + // The Agent Tokens page needs host.read, which this user lacks. + await actor.agent.get('/hosts/agentTokens').set(...HTML).expect(403); + const [row] = await waitFor({createdBy: user.username, type: 'access.page'}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.text, 'GET /hosts/agentTokens'); + }); + + it('records no row for the datatable and task-history POST reads', async () => { + const start = Date.now(); + await as(adminSession).post('/api/v1/host/datatable').send({draw: 1, start: 0, length: 5}).expect(200); + await as(adminSession).post('/api/v1/host/columns').send({}).expect(200); + // /task-history is not under /api/v1, so Sails CSRF applies to it. + const {body: csrf} = await adminSession.agent.get('/csrfToken').expect(200); + await as(adminSession).post('/task-history').set('X-CSRF-Token', csrf._csrf).send({period: 1}).expect(200); + await as(adminSession).post('/api/v1/auth/token').expect(200); + await sentinel(adminSession, start); + const rows = await rowsOf({createdBy: admin.username}, start); + assert.deepStrictEqual(rows.map((r) => r.text).filter((t) => !/sentinel/.test(t)), []); + }); + + it('records a cookie request that apiCsrfGuard refuses as denied', async () => { + const host = await mkHost(); + const start = Date.now(); + // No X-Requested-With: the guard refuses a plain cookie mutation. + await adminSession.agent.put(`/api/v1/host/${host.id}`).send({description: 'x'}).expect(403); + const [row] = await waitFor({type: 'access.api', createdBy: admin.username, text: `PUT /api/v1/host/${host.id}`}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.permission, '', 'the guard is not a permission check'); + }); + + it('records a Sails CSRF refusal of a page form as denied', async () => { + const start = Date.now(); + // A page form POST with no _csrf field: Sails answers 403 before any route runs. + await adminSession.agent.post('/hosts/create').type('form').set(...HTML).send({name: short()}).expect(403); + const rows = await waitFor({type: 'access.page', createdBy: admin.username, text: 'POST /hosts/create'}, start); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({type: 'access.page', createdBy: admin.username, text: 'POST /hosts/create'}, start)).length, 1, 'one row per request'); + assert.strictEqual(rows[0].outcome, 'denied'); + }); + + it('records a refused or failed full-page save by its outcome, not by the 302', async () => { + const user = await mkUser('pagesave', {permissions: {stock: {host: {read: true, update: true, create: true}}}}); + const actor = await signIn(user.username); + const host = await mkHost(); + const csrf = async () => (await actor.agent.get('/csrfToken').expect(200)).body._csrf; + + // A refused group change: the save redirects, and the row says denied. + let token = await csrf(); + let start = Date.now(); + await actor.agent.post(`/hosts/edit/${host.id}`).type('form').set(...HTML) + .send({name: host.name, groups: uniq(), _csrf: token}).expect(302); + let [row] = await waitFor({type: 'access.page', createdBy: user.username}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.permission, 'group.update'); + await sentinel(actor, start); + assert.strictEqual((await rowsOf({type: 'access.page', createdBy: user.username}, start)).length, 1); + + // Any other error: failed. + token = await csrf(); + start = Date.now(); + await actor.agent.post(`/hosts/edit/${host.id}`).type('form').set(...HTML) + .send({name: host.name, macs: 'not a mac', _csrf: token}).expect(302); + [row] = await waitFor({type: 'access.page', createdBy: user.username}, start); + assert.strictEqual(row.outcome, 'failed'); + assert.strictEqual(row.permission, 'host.update'); + assert.match(row.text, /^HTTP 302 POST \/hosts\/edit\//); + }); + + it('records a 500 from a write as failed, with HTTP 500 in the text', async () => { + const start = Date.now(); + // A well-formed id that matches no host: update.js reads obj.id of null. + const id = '0'.repeat(24); + const res = await as(adminSession).put(`/api/v1/host/${id}`).send({description: 'x'}); + assert.strictEqual(res.status, 500); + const [row] = await waitFor({type: 'access.api', createdBy: admin.username, text: `HTTP 500 PUT /api/v1/host/${id}`}, start); + assert.strictEqual(row.outcome, 'failed'); + }); + + it('writes no query string into the text', async () => { + const host = await mkHost(); + const start = Date.now(); + await as(adminSession).put(`/api/v1/host/${host.id}?token=secret`).send({description: 'q'}).expect(200); + const [row] = await waitFor({type: 'access.api', createdBy: admin.username, subjectId: host.id}, start); + assert.strictEqual(row.text, `PUT /api/v1/host/${host.id}`); + }); + + it('records the group permission that refused a membership change', async () => { + const noGroup = await mkUser('nogroup', {permissions: {stock: {host: {read: true, update: true, create: true}}}}); + const updateOnly = await mkUser('grpupd', {permissions: {stock: {host: {read: true, update: true, create: true}, group: {update: true}}}}); + const host = await mkHost(); + const newName = uniq(); + + let actor = await signIn(noGroup.username); + let start = Date.now(); + await as(actor).put(`/api/v1/host/${host.id}`).send({groups: [newName]}).expect(403); + let [row] = await waitFor({type: 'access.api', createdBy: noGroup.username}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.permission, 'group.update'); + + // Through host/bulk. + start = Date.now(); + await as(actor).post('/api/v1/host/bulk').send({id: [host.id], addGroups: newName}).expect(403); + [row] = await waitFor({type: 'access.api', createdBy: noGroup.username, text: 'POST /api/v1/host/bulk'}, start); + assert.strictEqual(row.permission, 'group.update'); + + // A new name with group.update but no group.create. + actor = await signIn(updateOnly.username); + start = Date.now(); + await as(actor).post('/api/v1/host').send({name: short(), groups: [newName]}).expect(403); + [row] = await waitFor({type: 'access.api', createdBy: updateOnly.username}, start); + assert.strictEqual(row.outcome, 'denied'); + assert.strictEqual(row.permission, 'group.create'); + }); + + it('records the permission of an inline check that answers 403', async () => { + const user = await mkUser('inline'); + const actor = await signIn(user.username); + const start = Date.now(); + await as(actor).get('/api/v1/host/groups').expect(403); + await as(actor).post('/api/v1/image/scan').expect(403); + await as(actor).get('/api/v1/agent/tokens').expect(403); + const rows = await waitFor({createdBy: user.username}, start, 3); + assert.deepStrictEqual(rows.map((r) => r.permission).sort(), ['host.read', 'host.read', 'image.create']); + }); + + it('records a denied agent route by the paths it takes, not under /agent/v1/', async () => { + const start = Date.now(); + await supertest(app()).post('/agent/v1/enroll').send({protocol: 2}).expect(426); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({text: 'POST /agent/v1/enroll'}, start)).length, 0); + }); + }); + + describe('the fog-agent events', () => { + const Enrollment = () => sails.models.agentenrollment; + const fingerprints = []; + async function newCsr() { + const c = await agentCsr.newCsr(); + fingerprints.push(c.fingerprint); + return c; + } + const body = (c, macs, extra = {}) => Object.assign({ + protocol: 1, + agent_version: 'v0.0.1', + os: 'linux', + arch: 'amd64', + hostname: '', + identity: {system_uuid: '', system_serial: '', board_serial: '', chassis_asset: '', macs}, + csr_pem: c.pem + }, extra); + const enroll = (b) => supertest(app()).post('/agent/v1/enroll').send(b); + const rowOf = (c) => Enrollment().findOne({fingerprint: c.fingerprint}); + const eventsOf = (hostId, start) => rowsOf({type: 'agent.enroll', subjectId: hostId}, start); + + after(async () => { + const rows = await Enrollment().find({fingerprint: fingerprints}); + await sails.models.host.destroy({id: rows.map((r) => r.host).filter(Boolean)}); + await Enrollment().destroy({fingerprint: fingerprints}); + }); + + it('first contact: waiting: , by (system) with authSource anonymous; a pending repeat writes none', async () => { + const m = mac(); + const host = await mkHost({macs: [bare(m)]}); + const c = await newCsr(); + const start = Date.now(); + await enroll(body(c, [m])).expect(202); + const [row] = await waitFor({type: 'agent.enroll', subjectId: host.id}, start); + assert.strictEqual(row.text, 'waiting: known-host-no-agent'); + assert.strictEqual(row.createdBy, '(system)'); + assert.strictEqual(row.authSource, 'anonymous'); + assert.strictEqual(row.subjectType, 'host'); + assert.strictEqual(row.subjectLabel, host.name); + assert.strictEqual(row.renderable, true); + assert.strictEqual(row.permission, ''); + await enroll(body(c, [m])).expect(202); + await enroll(body(c, [m])).expect(202); + await sentinel(adminSession, start); + assert.strictEqual((await eventsOf(host.id, start)).length, 1, 'an unchanged pending repeat writes no row'); + }); + + it('first contact with no MAC: waiting: no MAC address reported', async () => { + const hostname = short(); + const c = await newCsr(); + const start = Date.now(); + await enroll(body(c, [], {hostname})).expect(202); + const [row] = await waitFor({type: 'agent.enroll', subjectLabel: hostname}, start); + assert.strictEqual(row.text, 'waiting: no MAC address reported'); + assert.strictEqual(row.subjectId, ''); + }); + + it('admin approve and deny: issued via admin, key <16>, and denied by , as the admin, with no access row', async () => { + const m1 = mac(); + const m2 = mac(); + const h1 = await mkHost({macs: [bare(m1)]}); + const h2 = await mkHost({macs: [bare(m2)]}); + const c1 = await newCsr(); + const c2 = await newCsr(); + await enroll(body(c1, [m1])).expect(202); + await enroll(body(c2, [m2])).expect(202); + const start = Date.now(); + await as(adminSession).post(`/api/v1/agent/enrollment/${(await rowOf(c1)).id}/approve`).expect(200); + await as(adminSession).post(`/api/v1/agent/enrollment/${(await rowOf(c2)).id}/deny`).expect(200); + const [issued] = await waitFor({type: 'agent.enroll', subjectId: h1.id, createdBy: admin.username}, start); + assert.strictEqual(issued.text, `issued via admin, key ${c1.fingerprint.slice(0, 16)}`); + assert.strictEqual(issued.authSource, 'session'); + assert.strictEqual(issued.renderable, true); + const [denied] = await waitFor({type: 'agent.enroll', subjectId: h2.id, createdBy: admin.username}, start); + assert.strictEqual(denied.text, `denied by ${admin.username}`); + await sentinel(adminSession, start); + assert.strictEqual((await rowsOf({createdBy: admin.username, type: 'access.api'}, start)) + .filter((r) => r.text.startsWith('POST /api/v1/agent/enrollment/')).length, 0, 'the seam added a row beside the explicit event'); + }); + + it('reissue: waiting: reissue, and a token auto-approve: issued via token', async () => { + const m = mac(); + const host = await mkHost({macs: [bare(m)]}); + const c = await newCsr(); + await enroll(body(c, [m])).expect(202); + await sails.helpers.agent.issue.with({row: await rowOf(c), via: 'admin', by: admin.username, keepCert: true}); + await enroll(body(c, [m])).expect(200); + const start = Date.now(); + await enroll(body(c, [m])).expect(202); + const [again] = await waitFor({type: 'agent.enroll', subjectId: host.id}, start); + assert.strictEqual(again.text, 'waiting: reissue'); + assert.strictEqual(again.authSource, 'anonymous'); + + const m2 = mac(); + const h2 = await mkHost({macs: [bare(m2)]}); + const secret = crypto.randomBytes(24).toString('hex'); + await sails.models.agentenrolltoken.create({ + name: short(), uses: 1, expires: Date.now() + 3600000, createdBy: 'mocha', + hash: crypto.createHash('sha256').update(secret).digest('hex') + }); + const c2 = await newCsr(); + const start2 = Date.now(); + await enroll(body(c2, [m2], {token: secret})).expect(200); + const [via] = await waitFor({type: 'agent.enroll', subjectId: h2.id}, start2); + assert.strictEqual(via.text, `issued via token, key ${c2.fingerprint.slice(0, 16)}`); + assert.strictEqual(via.createdBy, '(system)'); + assert.strictEqual(via.authSource, 'anonymous'); + await sentinel(adminSession, start2); + assert.ok(!JSON.stringify(await rowsOf({}, start2)).includes(secret), 'a row holds the token'); + assert.strictEqual((await eventsOf(h2.id, start2)).length, 1); + }); + + it('token mint and revoke: the 1.6 texts, no token and no hash, no access row', async () => { + const start = Date.now(); + const name = short(); + const expires = Date.now() + 3600000; + const stamp = new Date(expires).toISOString().slice(0, 19).replace('T', ' '); + const minted = await as(adminSession).post('/api/v1/agent/token').send({name, uses: 2, expires}).expect(200); + const [mint] = await waitFor({type: 'agent.token', subjectLabel: name}, start); + assert.strictEqual(mint.text, `minted, 2 use(s), expires ${stamp}`); + assert.strictEqual(mint.subjectType, 'agentenrolltoken'); + assert.strictEqual(mint.subjectId, minted.body.id); + assert.strictEqual(mint.permission, 'host.create'); + assert.strictEqual(mint.createdBy, admin.username); + assert.strictEqual(mint.renderable, true); + + const name2 = short(); + const unlimited = await as(adminSession).post('/api/v1/agent/token').send({name: name2, uses: -1, expires}).expect(200); + const [mint2] = await waitFor({type: 'agent.token', subjectLabel: name2}, start); + assert.strictEqual(mint2.text, `minted, unlimited uses, expires ${stamp}`); + + await as(adminSession).del(`/api/v1/agent/token/${minted.body.id}`).expect(200); + const revoked = await waitFor({type: 'agent.token', subjectId: minted.body.id}, start, 2); + assert.strictEqual(revoked[1].text, `revoked by ${admin.username}`); + assert.strictEqual(revoked[1].permission, 'host.destroy'); + assert.strictEqual(revoked[1].subjectLabel, name); + + await sentinel(adminSession, start); + const all = await rowsOf({}, start); + const json = JSON.stringify(all); + for (const token of [minted.body.token, unlimited.body.token]) { + assert.ok(!json.includes(token) && !json.includes(crypto.createHash('sha256').update(token).digest('hex')), 'a row holds a token or a hash'); + } + assert.strictEqual(all.filter((r) => r.type === 'access.api' && /\/agent\/token/.test(r.text)).length, 0); + }); + }); +}); diff --git a/test/lifecycle.test.js b/test/lifecycle.test.js index a7cef01..2b0ffc6 100644 --- a/test/lifecycle.test.js +++ b/test/lifecycle.test.js @@ -1,7 +1,19 @@ const sails = require('sails'); +// The names the suite creates. Every test names its users, hosts, tokens, +// attempted sign-in names and agent hostnames with one of the PREFIXES, and +// the suite admin below is the other actor. The `after` below removes the +// AuditLog rows that carry one of them and nothing else: the suite may run +// while a dev server on 1337 writes real audit rows to the same database +// (package.json custom-tests), and those rows are never touched. +const ADMIN = 'mochatestadmin'; +const PREFIXES = ['mocha', 'mc', 'mpa', 'mph', 'testmochauser']; +// When the run started: only a row at or after it is the suite's. +let startedAt; + before(function(done) { this.timeout(11000); + startedAt = Date.now(); sails.lift({ // These were PORT=1338 and sails_hooks__grunt=false in the npm script. // That syntax fails in Windows cmd, so the options live here instead. @@ -34,14 +46,20 @@ before(function(done) { ); }); }); -after((done) => { - Role.destroy({name: 'Mocha_Test_Admin'}, (err) => { - if (err) return done(err); - User.destroy({username: 'mochatestadmin'}, (err) => { - if (err) return done(err); - sails.lower(done); - }); - }); +after(async () => { + // The seam writes after the response finishes, so let the last write land. + await new Promise((resolve) => setTimeout(resolve, 200)); + await AuditLog.destroy({and: [ + {createdAt: {'>=': startedAt}}, + {or: [ + {createdBy: {in: [ADMIN]}}, + ...PREFIXES.map((prefix) => ({createdBy: {startsWith: prefix}})), + ...PREFIXES.map((prefix) => ({subjectLabel: {startsWith: prefix}})) + ]} + ]}); + await Role.destroy({name: 'Mocha_Test_Admin'}); + await User.destroy({username: ADMIN}); + await new Promise((resolve, reject) => sails.lower((err) => (err ? reject(err) : resolve()))); }); describe('Basic Tests ::', () => { @@ -49,3 +67,5 @@ describe('Basic Tests ::', () => { return true; }); }); + +module.exports = {ADMIN, PREFIXES}; diff --git a/tools/setup/index.js b/tools/setup/index.js index 0b8b4fb..f76e97e 100644 --- a/tools/setup/index.js +++ b/tools/setup/index.js @@ -167,6 +167,16 @@ async.waterfall([ payload.pkiDir = pkiOverride || pki.pkiDirFor(config.appPath); const pkiLines = pkiOverride ? `, pkiDir: '${jsStr(pkiOverride)}'` : ''; + // A re-run also keeps an http.trustProxy that the old local.js set: behind + // a reverse proxy it makes the audit rows record the client address. The + // read above proved that local.js loads. JSON is valid JS for a boolean, + // a number, a string or a list. + const oldLocal = fs.existsSync(localCfg) ? require(localCfg) : undefined; + const trustProxy = oldLocal && oldLocal.http ? oldLocal.http.trustProxy : undefined; + const httpLines = trustProxy === undefined ? '' : ` + http: { + trustProxy: ${JSON.stringify(trustProxy)} + },`; // Only emit credentials when a username was provided. Emitting an empty (or // undefined) user+password makes sails-mongo attempt authentication and fail // against a no-auth Mongo with "Authentication failed". @@ -207,7 +217,7 @@ async.waterfall([ // Production refuses to lift without the list. Re-run npm run setup to change it. sockets: { onlyAllowOrigins: [${payload.server.origins.map((o) => `'${jsStr(o)}'`).join(', ')}] - }, + },${httpLines} custom: { imageStorePath: '${jsStr(payload.server.imageStorePath)}'${pkiLines} },