fix vault 403 + shared secrets (v1.21.0)

- vault_broker: always reconcile policy content before serving a cached
  token (compare-and-skip), so stale stored policies can't cause a recurring
  403 'permission denied'; policy content is parsed live by OpenBao, so edits
  apply to existing tokens immediately.
- Shared secrets: publish to secret/shared/<owner>/<slug>; grant read to users
  and apps by editing the grantee's policy content (live-applied). New
  SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a
  Shared tab in the vault UI.
- package.json + lockfile bumped to 1.21.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-03 22:13:58 -04:00
parent 2612b0e3ab
commit 948fef4adc
10 changed files with 685 additions and 31 deletions
+3
View File
@@ -17,6 +17,8 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -31,6 +33,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// SharedSecret — a secret the owner has published to the shared namespace so it
// can be shared with other users and/or downstream apps.
//
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
// never in the DB. This row is metadata only (owner + slug + description) and is
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
// edited to add `read` on the exact shared path (see vault_broker.js — policy
// content is parsed live at token use, so a grant takes effect immediately with
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
//
// `slug` is unique and immutable in practice — it is embedded in the shared path
// and in grantee policy rules, so changing it would require rewriting policies.
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
// ORM on create.
const { Model } = require('@simpleworkjs/orm');
class SharedSecret extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
// Unique so two owners can't collide on the same shared path.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The publishing user's uid — also the shared path's namespace segment.
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
// Optional human description shown in the Shared tab.
description: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
static pathFor(ownerUid, slug) {
return `shared/${ownerUid}/${slug}`;
}
path() {
return SharedSecret.pathFor(this.ownerUid, this.slug);
}
// Look up by slug (unique). Returns the row or null.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { SharedSecret };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
// <secretId>".
//
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
// content live at token use, the grant applies to the grantee's existing token
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
//
// granteeType distinguishes the two principal kinds:
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
// capability is currently always 'read' (grantees are read-only); the column is
// a string so later capabilities could be added without a migration.
//
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
const { Model } = require('@simpleworkjs/orm');
const GRANTEE_TYPES = ['user', 'app'];
const CAPABILITIES = ['read'];
class SharedSecretGrant extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// FK to SharedSecret.id.
secretId: { type: 'string', isRequired: true, min: 1 },
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
granteeType: { type: 'string', isRequired: true, min: 1 },
// The grantee's uid (for 'user') or app name (for 'app').
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
// Access level — 'read' today.
capability: { type: 'string', isRequired: true, default: 'read' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All grants for a given grantee (user uid or app name). Used to rebuild the
// grantee's policy content so every granted shared path is present/absent.
static async listForGrantee(granteeType, granteeId) {
return this.list({ where: { granteeType, granteeId } });
}
}
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };