fix vault 403 for real (v1.23.0)
- /api/vault proxy now injects X-Vault-Token: the proxy declared its request
hook with http-proxy-middleware v3 syntax (on: { proxyReq }), which the
installed HPM v2 silently ignores — so every vault call reached OpenBao
unauthenticated (the recurring 403). Rewritten as v2 onProxyReq.
- Header injection ordered before fixRequestBody (the body write flushes
headers; setting X-Vault-Token after it failed on every POST/PUT).
- initORM add-only schema heal: sequelize.sync() never ALTERs, so newer columns
(PluginInstance.lastLog) are now added via describeTable + addColumn.
- Long-lived external-app tokens via sso-app role (768h periodic); VaultAppToken
stores each app token's accessor and renews it at boot + every 6h; re-minting
revokes the previous token via its accessor.
- Wire-level tests for the vault proxy + app-token accessor lifecycle.
- package.json + lockfile bumped to 1.23.0.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+34
-1
@@ -19,6 +19,7 @@ const { Webhook } = require('./webhook');
|
||||
const { PluginInstance } = require('./plugin_instance');
|
||||
const { SharedSecret } = require('./shared_secret');
|
||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||
const { VaultAppToken } = require('./vault_app_token');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -33,16 +34,48 @@ async function initORM() {
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
SharedSecret, SharedSecretGrant,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] ORM initialized successfully');
|
||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||
await healSchema();
|
||||
} catch (err) {
|
||||
console.error('[initORM] ORM initialization failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||
// which creates missing tables but never touches existing ones — so a column
|
||||
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||
// in an upgraded deployment's database and every query on the model fails
|
||||
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||
// any dialect via the query interface, and fail-soft per column so one bad
|
||||
// attribute can't take the boot down.
|
||||
async function healSchema() {
|
||||
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||
if (!adapter || !adapter.sequelize) return;
|
||||
const sequelize = adapter.sequelize;
|
||||
const qi = sequelize.getQueryInterface();
|
||||
for (const SM of Object.values(sequelize.models)) {
|
||||
const table = SM.getTableName();
|
||||
let existing;
|
||||
try { existing = await qi.describeTable(table); }
|
||||
catch (e) { continue; } // no table yet — sync() handles creation
|
||||
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||
const col = attr.field || name;
|
||||
if (existing[col]) continue;
|
||||
try {
|
||||
await qi.addColumn(table, col, attr);
|
||||
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||
} catch (e) {
|
||||
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.initORM = initORM;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
|
||||
// from the vault UI (Apps tab), so sso can keep the token alive.
|
||||
//
|
||||
// The token itself is shown ONCE at mint and never stored (a stolen accessor
|
||||
// cannot authenticate — it can only look up, renew, or revoke its token, and
|
||||
// only the sso broker's policy grants those endpoints). App tokens are minted
|
||||
// through the sso-app role as PERIODIC tokens: they live forever, but only if
|
||||
// something renews them inside every period window. That something is sso's
|
||||
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
|
||||
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
|
||||
// stays valid as long as sso itself is running, with no renewal code needed in
|
||||
// the downstream app.
|
||||
//
|
||||
// One row per app name: re-minting an app's token revokes the previous token
|
||||
// via its accessor (no zombie credentials) and replaces the row.
|
||||
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
|
||||
class VaultAppToken extends Model {
|
||||
static fields = {
|
||||
id: { type: 'uuid', primaryKey: true },
|
||||
// The external app's name — also its policy (app-<name>) and KV namespace
|
||||
// (secret/apps/<name>/). Unique: one live token per app.
|
||||
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||
// The minted token's accessor (renew/revoke handle, cannot authenticate).
|
||||
accessor: { type: 'string', isRequired: true, max: 128 },
|
||||
// Renewal bookkeeping, updated by the renewal loop.
|
||||
lastRenewedAt: { type: 'integer' },
|
||||
lastError: { type: 'text' },
|
||||
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
};
|
||||
|
||||
static async getByName(name) {
|
||||
const rows = await this.list({ where: { name } });
|
||||
return rows[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VaultAppToken };
|
||||
Reference in New Issue
Block a user