memhouse 0.18.12 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
package/memhouse/share.js
CHANGED
|
@@ -24,6 +24,8 @@ const SCOPE_COLUMNS = {
|
|
|
24
24
|
folder: { column: () => 'folder', quote: true },
|
|
25
25
|
host: { column: () => 'host', quote: true },
|
|
26
26
|
source: { column: () => 'source', quote: true },
|
|
27
|
+
// Schema 3 names the editor `harness`; `source=` means the same there.
|
|
28
|
+
harness: { column: () => 'harness', quote: true },
|
|
27
29
|
// `sessions` has no `ts`; its clock is `created_at`. One literal predicate cannot span
|
|
28
30
|
// the rooms, so time scopes render per room.
|
|
29
31
|
since: { column: (room) => (room === 'sessions' ? 'created_at' : 'ts'), op: '>=' },
|
|
@@ -61,4 +63,688 @@ function policyName(user, room) {
|
|
|
61
63
|
return `mh_share_${user}_${room}`;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
// ── schema 3 (memhouse 0.20) ───────────────────────────────────────────────────────────────
|
|
67
|
+
// A share is SELECT on exactly the member's six conversation tables (harness-sessions-sql's profile) —
|
|
68
|
+
// never a wildcard (a 0.18 full share's `<m>_*` also reached meta, events and the stat tables) and
|
|
69
|
+
// never the archive database. A scope is a row policy on each of the six. Revoking takes back the
|
|
70
|
+
// grants AND the policies: a policy left behind would quietly narrow a later re-share.
|
|
71
|
+
|
|
72
|
+
const V3_TABLES = ['session_parts', 'session_usage', 'session_links', 'session_lifecycle', 'calls', 'sessions'];
|
|
73
|
+
// The scope columns each table has; a scope on a column a table lacks lets no row of it through
|
|
74
|
+
// (fail closed): a project-scoped reader sees no usage rows (the sessions row carries the totals) and
|
|
75
|
+
// a time-scoped one no links. A row whose ts is null never matches a time scope either.
|
|
76
|
+
const SCOPE_HAS = {
|
|
77
|
+
project: ['session_parts', 'calls', 'sessions'], folder: ['session_parts', 'calls', 'sessions'],
|
|
78
|
+
time: { sessions: 'started', session_parts: 'ts', session_usage: 'ts', calls: 'ts', session_lifecycle: 'ts' },
|
|
79
|
+
};
|
|
80
|
+
const LEGACY_ROOMS = ['messages', 'tool_calls', 'sessions'];
|
|
81
|
+
// The first schema-3 layout of the 0.20 development builds: a share made there granted <m>_turns
|
|
82
|
+
// (with <m>_calls and <m>_sessions, which the profile reuses by name) and filtered it.
|
|
83
|
+
const DEV_ROOMS = ['turns'];
|
|
84
|
+
|
|
85
|
+
/** A scope's predicate for one schema-3 table. `session=` takes a ref (its store included) or a bare id. */
|
|
86
|
+
function scopePredicateV3(scope, table, sqlStr, parseSessionRef) {
|
|
87
|
+
return scope.map(({ key, value }) => {
|
|
88
|
+
if (key === 'since' || key === 'until') {
|
|
89
|
+
const col = SCOPE_HAS.time[table];
|
|
90
|
+
return col ? `${col} ${key === 'since' ? '>=' : '<'} parseDateTimeBestEffort(${sqlStr(value)})` : '0';
|
|
91
|
+
}
|
|
92
|
+
if ((key === 'project' || key === 'folder') && !SCOPE_HAS[key].includes(table)) return '0';
|
|
93
|
+
if (key === 'source' || key === 'harness') return `harness = ${sqlStr(value)}`;
|
|
94
|
+
if (key === 'session') {
|
|
95
|
+
if (!value.includes(':')) return `session_id = ${sqlStr(value)}`;
|
|
96
|
+
const r = parseSessionRef(value);
|
|
97
|
+
const parts = [`harness = ${sqlStr(r.harness)}`, `session_id = ${sqlStr(r.session_id)}`];
|
|
98
|
+
if (r.host != null && r.harness_path != null) parts.push(`host = ${sqlStr(r.host)}`, `harness_path = ${sqlStr(r.harness_path)}`, `incarnation = ${sqlStr(r.incarnation || '')}`);
|
|
99
|
+
return `(${parts.join(' AND ')})`;
|
|
100
|
+
}
|
|
101
|
+
return `${SCOPE_COLUMNS[key].column(table)} = ${sqlStr(value)}`;
|
|
102
|
+
}).join(' AND ');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Names reach the DDL below as identifiers. Each is checked against the one shape memhouse
|
|
106
|
+
// creates (as house.assertUsableName) and then quoted, so a name read back from the server
|
|
107
|
+
// (system.users, a meta record) can neither break the statement nor extend it.
|
|
108
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
109
|
+
function assertName(name, what) {
|
|
110
|
+
if (typeof name !== 'string' || !NAME_RE.test(name)) throw new Error(`'${name}' cannot be a ${what} — expected [A-Za-z][A-Za-z0-9_]*`);
|
|
111
|
+
return name;
|
|
112
|
+
}
|
|
113
|
+
/** A quoted ClickHouse identifier: backticks, with backslash and backtick escaped. */
|
|
114
|
+
function ident(name) { return `\`${String(name).replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``; }
|
|
115
|
+
/** A checked, quoted `db.table` (each part one memhouse makes). */
|
|
116
|
+
function qual(fq) {
|
|
117
|
+
const parts = String(fq).split('.');
|
|
118
|
+
if (parts.length !== 2) throw new Error(`'${fq}' is not a db.table name`);
|
|
119
|
+
return parts.map((x) => ident(assertName(x, 'table name part'))).join('.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The statements of a schema-3 share, in a fail-closed order: a scoped share writes its filters
|
|
124
|
+
* BEFORE any grant; the 0.18 wildcard is taken back before the table grants (it would reach past
|
|
125
|
+
* the filters, and past the six tables); a full share drops leftover filters only after it is
|
|
126
|
+
* granted (until then they narrow, never widen). Returns [{ sql, phase }], phase 'filter' | 'grant'
|
|
127
|
+
* | 'clear'. Throws on a name that is not one memhouse makes.
|
|
128
|
+
*/
|
|
129
|
+
function shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef }) {
|
|
130
|
+
assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
|
|
131
|
+
const tbl = (t) => `${ident(db)}.${ident(`${member}_${t}`)}`;
|
|
132
|
+
const steps = [];
|
|
133
|
+
if (scope) for (const t of V3_TABLES) steps.push({ phase: 'filter', sql: `CREATE ROW POLICY OR REPLACE ${ident(policyName(user, t))} ON ${tbl(t)} USING ${scopePredicateV3(scope, t, sqlStr, parseSessionRef)} TO ${ident(user)}` });
|
|
134
|
+
// Exact tables only. No REVOKE on `<m>_*` here: a prefix wildcard also matches another
|
|
135
|
+
// member's tables when that member's name starts with this one's (bob, bob_smith) and would
|
|
136
|
+
// strip the reader's grants there, or that member's own. A 0.18 wildcard grant still held is
|
|
137
|
+
// taken back by the admin's move (moveSharePlan), only when the reader holds it.
|
|
138
|
+
steps.push(...V3_TABLES.map((t) => ({ phase: 'grant', sql: `GRANT SELECT ON ${tbl(t)} TO ${ident(user)}` })));
|
|
139
|
+
if (!scope) for (const t of V3_TABLES) steps.push({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` });
|
|
140
|
+
return steps;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Revoking a schema-3 share: every grant first — the six tables, the 0.18 rooms a share made before
|
|
145
|
+
* the conversion still names in <db>, and the development layout's <m>_turns — then every filter,
|
|
146
|
+
* the 0.18 and development ones included. Exact
|
|
147
|
+
* names only: a 0.18 wildcard grant (`<m>_*`) is taken back by wildcardRevoke, and only when the
|
|
148
|
+
* reader holds it and it matches nothing but this member's tables (wildcardOnRevoke). (The rooms renamed into the
|
|
149
|
+
* archive are the admin's: a member holds no rights there, so `migrate --v3-shares` clears them.)
|
|
150
|
+
*/
|
|
151
|
+
function revokeV3Plan({ db, member, user }) {
|
|
152
|
+
assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
|
|
153
|
+
const tbl = (t) => `${ident(db)}.${ident(`${member}_${t}`)}`;
|
|
154
|
+
const NEW = V3_TABLES.map((t) => `${db}.${member}_${t}`);
|
|
155
|
+
const oldRooms = LEGACY_ROOMS.map((r) => `${db}.${member}_${r}`).filter((x) => !NEW.includes(x));
|
|
156
|
+
return [
|
|
157
|
+
...V3_TABLES.map((t) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${tbl(t)} FROM ${ident(user)}` })),
|
|
158
|
+
...oldRooms.map((fq) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${qual(fq)} FROM ${ident(user)}` })),
|
|
159
|
+
...DEV_ROOMS.map((t) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${tbl(t)} FROM ${ident(user)}` })),
|
|
160
|
+
...V3_TABLES.map((t) => ({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` })),
|
|
161
|
+
...legacyPolicyDrops({ db, member, user, keep: NEW }),
|
|
162
|
+
...DEV_ROOMS.map((t) => ({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` })),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Taking back a 0.18 wildcard share (`<m>_*`). Call it only for a reader who holds that grant,
|
|
167
|
+
* when it matches nothing but this member's tables (wildcardOnRevoke). */
|
|
168
|
+
function wildcardRevoke({ db, member, user }) {
|
|
169
|
+
assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
|
|
170
|
+
return { phase: 'grant', sql: `REVOKE SELECT ON ${db}.${member}_* FROM ${ident(user)}` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* May a schema-3 share be written next to what the reader holds? `heldWildcard`: true when the
|
|
175
|
+
* reader still holds the member's 0.18 wildcard, false when they do not, null when that could
|
|
176
|
+
* not be read. Only a known false proceeds: a share beside the wildcard would not narrow it, and
|
|
177
|
+
* an unread answer is no evidence either way.
|
|
178
|
+
*/
|
|
179
|
+
function shareGate(heldWildcard) {
|
|
180
|
+
if (heldWildcard === false) return null;
|
|
181
|
+
return { refuse: heldWildcard === true ? 'held' : 'unknown' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Members whose names collide with `member`'s in one house: one starts with the other's name and
|
|
186
|
+
* an underscore (bob, bob_smith). A member's grant is the prefix wildcard `<db>.<m>_*`, so the
|
|
187
|
+
* shorter name's grant also reaches the longer name's tables, to read and to write, and sometimes
|
|
188
|
+
* the other way round (wildcardOnRevoke). `tables` is the house's table names; a member is a prefix
|
|
189
|
+
* of a table ending in _meta, _session_parts, _turns or _messages.
|
|
190
|
+
*/
|
|
191
|
+
function memberCollisions(member, tables) {
|
|
192
|
+
const members = new Set();
|
|
193
|
+
for (const t of tables) { const m = /^(.+)_(meta|session_parts|turns|messages)$/.exec(String(t)); if (m) members.add(m[1]); }
|
|
194
|
+
return [...members].filter((x) => x !== member && (x.startsWith(`${member}_`) || member.startsWith(`${x}_`))).sort();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The names memhouse gives a member's tables in the house, as the part after `<m>_`: its tables,
|
|
198
|
+
// the copies migrate keeps of a 0.18 room while it moves one (`<room>__migrating`) and after
|
|
199
|
+
// (`<room>_pre_epoch`), and the scratch table share's probe makes and drops (`_mh_probe_<pid>`).
|
|
200
|
+
const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats',
|
|
201
|
+
...LEGACY_ROOMS.flatMap((r) => [`${r}_pre_epoch`, `${r}__migrating`])])];
|
|
202
|
+
const isMemberTable = (rest) => MEMBER_TABLES.includes(rest) || /^_mh_probe_\d+$/.test(rest);
|
|
203
|
+
const startsMemberTable = (p) => [...MEMBER_TABLES, '_mh_probe_1'].some((n) => n.startsWith(`${p}_`));
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A reader's 0.18 wildcard `<db>.<member>_*` when the member revokes them. A REVOKE on it takes
|
|
207
|
+
* the reader's grant on every table it matches, whoever's table that is. Other members whose names
|
|
208
|
+
* collide with this one's come from the house's tables (memberCollisions) and from its users,
|
|
209
|
+
* since a member with grants but no tables, or whose tables this credential cannot see, has no
|
|
210
|
+
* table to find it by. The wildcard matches the tables of:
|
|
211
|
+
* - a longer name, all of them: bob_* matches bob_smith's;
|
|
212
|
+
* - a shorter name when the rest of `member` starts one of its table names: bob_session_*
|
|
213
|
+
* matches bob's bob_session_parts, while bob_smith_* matches none of bob's (ClickHouse 26.7:
|
|
214
|
+
* REVOKE ON mem.bob_smith_* leaves the rest of mem.bob_* readable).
|
|
215
|
+
* And of `tables`, the house's table names this credential sees: a table under the member's
|
|
216
|
+
* prefix that is neither one of its names nor one of a reached member's is a stray, whose owner
|
|
217
|
+
* cannot be told. The member takes the wildcard back only when it matches nothing else.
|
|
218
|
+
* collisions null (tables unlisted), or tables or users null (unread): that is unknown, and it
|
|
219
|
+
* is left. `users` is the server's users narrowed to those that may be members of this house
|
|
220
|
+
* (holdsMemberGrant), so one person's account in another house is not a collision here.
|
|
221
|
+
* revoke: the member issues the REVOKE; left: the reader may still hold it afterwards;
|
|
222
|
+
* colliding: every colliding name found; reaches: those whose tables it also matches, and
|
|
223
|
+
* unseen: those of them found by their user alone; strays: other tables it matches (null: unknown).
|
|
224
|
+
*/
|
|
225
|
+
function wildcardOnRevoke({ member, collisions, heldWildcard, tables = null, users = null }) {
|
|
226
|
+
let colliding = null; let reaches = null; let strays = null;
|
|
227
|
+
if (collisions && tables && users) {
|
|
228
|
+
const near = (x) => x !== member && (x.startsWith(`${member}_`) || member.startsWith(`${x}_`));
|
|
229
|
+
colliding = [...new Set([...collisions, ...users.map(String).filter(near)])].sort();
|
|
230
|
+
reaches = colliding.filter((x) => x.startsWith(`${member}_`) || (member.startsWith(`${x}_`) && startsMemberTable(member.slice(x.length + 1))));
|
|
231
|
+
const holds = (m, t) => t.startsWith(`${m}_`) && isMemberTable(t.slice(m.length + 1));
|
|
232
|
+
strays = tables.map(String).filter((t) => t.startsWith(`${member}_`) && ![member, ...reaches].some((m) => holds(m, t))).sort();
|
|
233
|
+
}
|
|
234
|
+
const revoke = heldWildcard === true && !!reaches && !reaches.length && !strays.length;
|
|
235
|
+
// Found by its user alone: no table of theirs is in sight, so whether it is a member is unknown.
|
|
236
|
+
const unseen = reaches && reaches.filter((x) => !collisions.includes(x));
|
|
237
|
+
return { revoke, left: heldWildcard !== false && !revoke, colliding, reaches, unseen, strays };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* May `user` be a member of house `db`, from its SHOW GRANTS lines? A member owns its tables: it
|
|
242
|
+
* holds them, `<db>.<user>_…` (or the archive's, `<db>_archive.<user>_…`), with ALL, INSERT, or a
|
|
243
|
+
* CREATE, DROP, ALTER, OPTIMIZE or TRUNCATE privilege, or with the right to grant them, none of
|
|
244
|
+
* which a share gives (a share is SELECT only). Any such word counts, so an admin's CREATE USER
|
|
245
|
+
* ON * does too: a false positive only leaves a grant for the admin. A broader target that covers them counts too (`*.*`, `<db>.*`, `<db>.b*`), since
|
|
246
|
+
* SHOW GRANTS lists only the broader line: an admin who also ships. So a reader of a colliding
|
|
247
|
+
* member, a stray SELECT on `<db>.<user>_*`, and an account in another house do not count. A user
|
|
248
|
+
* holding a role may hold a member's grant through it, so it counts.
|
|
249
|
+
*/
|
|
250
|
+
function holdsMemberGrant(lines, db, user) {
|
|
251
|
+
if (rolesGranted(lines).length) return true;
|
|
252
|
+
const own = `${user}_`;
|
|
253
|
+
const covers = (t) => {
|
|
254
|
+
if (t === '*' || t === '*.*') return true;
|
|
255
|
+
const dot = t.indexOf('.');
|
|
256
|
+
if (dot < 0) return false;
|
|
257
|
+
const tdb = t.slice(0, dot); const tt = t.slice(dot + 1);
|
|
258
|
+
const dbs = [db, `${db}_archive`];
|
|
259
|
+
if (!(tdb.endsWith('*') ? dbs.some((d) => d.startsWith(tdb.slice(0, -1))) : dbs.includes(tdb))) return false;
|
|
260
|
+
if (!tt.endsWith('*')) return tt.startsWith(own);
|
|
261
|
+
const p = tt.slice(0, -1);
|
|
262
|
+
return own.startsWith(p) || p.startsWith(own);
|
|
263
|
+
};
|
|
264
|
+
return lines.some((line) => {
|
|
265
|
+
const { masked, names } = maskQuoted(line);
|
|
266
|
+
const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(masked);
|
|
267
|
+
if (!m || !covers(m[2].replace(/\u0000(\d+)\u0000/g, (_, i) => names[Number(i)]))) return false;
|
|
268
|
+
return /\bWITH GRANT OPTION\b/.test(masked) || /\b(ALL|INSERT|CREATE|DROP|ALTER|OPTIMIZE|TRUNCATE)\b/.test(m[1].replace(/\([^)]*\)/g, ''));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Of the server's `users`, those whose names collide with `member`'s and who may be members of
|
|
274
|
+
* house `db` (holdsMemberGrant). `read(name)` returns a user's SHOW GRANTS lines; one that cannot
|
|
275
|
+
* be read counts.
|
|
276
|
+
*/
|
|
277
|
+
async function collidingUsers({ users, member, db, read }) {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const x of users.map(String)) {
|
|
280
|
+
if (x === member || !(x.startsWith(`${member}_`) || member.startsWith(`${x}_`))) continue;
|
|
281
|
+
let maybe = true;
|
|
282
|
+
try { maybe = holdsMemberGrant(await read(x), db, x); } catch { /* unread: counts */ }
|
|
283
|
+
if (maybe) out.push(x);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Of the row policies in a member's database (system.row_policies, which can show other members'
|
|
290
|
+
* too, on tables this member cannot see), the ones on this member's own tables, by exact name.
|
|
291
|
+
* mine for the orphan check: any table memhouse names for the member (memberTargets: its
|
|
292
|
+
* 0.18 rooms, development and profile tables, meta, events, stats) and the share probe's
|
|
293
|
+
* scratch table (`<m>__mh_probe_<pid>`).
|
|
294
|
+
* grants for describing a grantee's scope: only the tables a share filters on this layout
|
|
295
|
+
* (schema 3: V3_TABLES and the development layout's; 0.18: its rooms), so a colliding
|
|
296
|
+
* name's table that spells a 0.18 room (bob_tool's calls is bob's tool_calls) is never
|
|
297
|
+
* read as this member's scope.
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* The statements that take SHOW ROW POLICIES back from a member, one per object its grants name
|
|
301
|
+
* it on (`SHOW GRANTS FOR <member>` lines). Members made before 0.20 hold it on <db>.<m>_*; it
|
|
302
|
+
* opens all of system.row_policies, every member's scoped shares included, and nothing memhouse
|
|
303
|
+
* does needs it. [] when the member holds none.
|
|
304
|
+
*/
|
|
305
|
+
function showPoliciesRevokes(lines, member) {
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const l of lines || []) {
|
|
308
|
+
const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s+(\S+?)(?:\s+WITH\s+GRANT\s+OPTION)?\s*;?\s*$/i.exec(String(l).trim());
|
|
309
|
+
if (!m || m[3].replace(/`/g, '') !== member) continue;
|
|
310
|
+
if (!/(^|,\s*)SHOW\s+(ROW\s+)?POLICIES(\s*,|$)/i.test(m[1])) continue;
|
|
311
|
+
const sql = `REVOKE SHOW ROW POLICIES ON ${m[2]} FROM ${ident(member)}`;
|
|
312
|
+
if (!out.includes(sql)) out.push(sql);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ownPolicies(policies, { db, member, schema3 }) {
|
|
318
|
+
const known = memberTargets({ db, archive: `${db}_archive`, member, legacy: null }).known;
|
|
319
|
+
const probe = new RegExp(`^${member}__mh_probe_\\d+$`);
|
|
320
|
+
const mine = policies.filter((p) => known.has(`${db}.${p.table}`) || probe.test(String(p.table)));
|
|
321
|
+
const shared = new Set((schema3 ? [...V3_TABLES, ...DEV_ROOMS] : LEGACY_ROOMS).map((t) => `${member}_${t}`));
|
|
322
|
+
return { mine, grants: mine.filter((p) => shared.has(String(p.table))) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** What else a left wildcard matches (wildcardOnRevoke), said in one clause. */
|
|
326
|
+
function wildcardReachText({ reaches, unseen = [], strays }, db) {
|
|
327
|
+
if (!reaches) return 'the house\'s tables or users could not be listed';
|
|
328
|
+
const parts = [];
|
|
329
|
+
const seen = reaches.filter((x) => !unseen.includes(x));
|
|
330
|
+
if (seen.length) parts.push(`the tables of ${seen.map((x) => `'${x}'`).join(', ')}`);
|
|
331
|
+
if (unseen.length) parts.push(`any table of ${unseen.map((x) => `'${x}'`).join(', ')} (a user whose name collides with yours and who may be a member here; no table of theirs is visible to you)`);
|
|
332
|
+
if (strays.length) parts.push(`${strays.map((t) => `${db}.${t}`).join(', ')}, which memhouse did not make for you`);
|
|
333
|
+
return `it also reaches ${parts.join(' and ')}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Who takes back a 0.18 wildcard the member could not, and how (`wc` from wildcardOnRevoke).
|
|
338
|
+
* Where member names collide, by hand: `migrate --v3-shares` refuses such a house. A REVOKE on the
|
|
339
|
+
* wildcard takes every table it matches, so strays are checked first. `--v3-shares` moves only a
|
|
340
|
+
* full share memhouse recorded as granted (classifyShare): `movable` says the record does (null:
|
|
341
|
+
* unread), and a revoke has just recorded it as revoked, so there it is by hand too.
|
|
342
|
+
*/
|
|
343
|
+
function wildcardAdminLine({ wc, movable = false, db, member, user }) {
|
|
344
|
+
// movable: true, the record says --v3-shares would move it; false, it would not; null, unread;
|
|
345
|
+
// 'check', it would only if the reader does not hold the wildcard (a scoped record, beside
|
|
346
|
+
// grants that could not be read).
|
|
347
|
+
const guide = 'docs/guides/troubleshooting.md, "Two member names that collide"';
|
|
348
|
+
const wildcard = `${db}.${member}_*`;
|
|
349
|
+
if (wc.colliding && wc.colliding.length) return `The admin settles it by hand, because member names collide in this house (${guide}).`;
|
|
350
|
+
if (wc.strays && wc.strays.length) return `The admin settles it by hand: a REVOKE on ${wildcard} would also take the reader's grants on ${wc.strays.map((t) => `${db}.${t}`).join(', ')}, so the admin checks whose they are first.`;
|
|
351
|
+
if (movable === true) {
|
|
352
|
+
return wc.colliding ? 'The admin settles it: memhouse migrate --v3-shares, or SHOW GRANTS and REVOKE by hand.'
|
|
353
|
+
: `The admin settles it: memhouse migrate --v3-shares, or by hand if member names collide, since it refuses such a house (${guide}).`;
|
|
354
|
+
}
|
|
355
|
+
const by = `REVOKE SELECT ON ${wildcard} FROM ${ident(user)}`;
|
|
356
|
+
if (movable === 'check') return `The admin checks SHOW GRANTS FOR ${ident(user)} first: if it holds ${wildcard}, ${by} by hand (memhouse migrate --v3-shares refuses a scoped share beside it); if not, memhouse migrate --v3-shares moves the recorded share${wc.colliding ? '' : `, unless member names collide, since it refuses such a house (${guide})`}.`;
|
|
357
|
+
const why = `${movable === null ? 'the share record could not be read, and ' : ''}memhouse migrate --v3-shares moves only a full share memhouse recorded as granted`;
|
|
358
|
+
return wc.colliding ? `The admin settles it by hand: ${by} (${why}).`
|
|
359
|
+
: `The admin settles it by hand: ${by} (${why}), once the house's tables and users show that no member's name collides with yours and no table under ${db}.${member}_ is one memhouse did not make for you (${guide}).`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── moving a 0.18 share onto schema 3 (memhouse migrate --v3-shares) ───────────────────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Every table of ONE member, by exact name: what a grant on this member can reach. Matching is by
|
|
366
|
+
* these names only, never by prefix: `mem.bob_` is also the start of `mem.bob_smith_turns`.
|
|
367
|
+
* `legacy` is the renamed rooms ({ messages, tool_calls, sessions } -> '<archive>.<m>_*_pre_v3').
|
|
368
|
+
*/
|
|
369
|
+
function memberTargets({ db, archive, member, legacy }) {
|
|
370
|
+
const t = (x) => `${db}.${member}_${x}`;
|
|
371
|
+
const wildcard = `${db}.${member}_*`;
|
|
372
|
+
const before = { full: [wildcard], scoped: LEGACY_ROOMS.map(t) };
|
|
373
|
+
const after = V3_TABLES.map(t);
|
|
374
|
+
const known = new Set([wildcard, ...before.scoped, ...after, ...DEV_ROOMS.map(t),
|
|
375
|
+
...['meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats'].map(t),
|
|
376
|
+
// The capture archive: the profile's two tables, and the development builds' two.
|
|
377
|
+
...['session_units', 'session_sources', 'records', 'files'].map((x) => `${archive}.${member}_${x}`), ...Object.values(legacy || {})]);
|
|
378
|
+
return { wildcard, before, after, known };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* What a user's SHOW GRANTS lines let them read of ONE member's tables (backticks dropped):
|
|
383
|
+
* an exact table of the member's, the member's wildcard, and anything broader that covers one of
|
|
384
|
+
* them — `*.*`, `<db>.*`, `<archive>.*`, or a prefix wildcard like `<db>.b*` that matches a table
|
|
385
|
+
* of the member's. `admin` is true for a server administrator (ALL, ACCESS MANAGEMENT or CREATE
|
|
386
|
+
* USER on *.*): they read everything by their own grants, which is no share.
|
|
387
|
+
*/
|
|
388
|
+
function selectTargets(lines, known) {
|
|
389
|
+
const on = new Set(); let admin = false;
|
|
390
|
+
const tables = [...known].filter((k) => !k.endsWith('*'));
|
|
391
|
+
const covers = (target) => {
|
|
392
|
+
if (known.has(target)) return true;
|
|
393
|
+
if (target === '*.*' || target === '*') return tables.length > 0;
|
|
394
|
+
const dot = target.indexOf('.');
|
|
395
|
+
const tdb = target.slice(0, dot); const ttab = target.slice(dot + 1);
|
|
396
|
+
if (!ttab.endsWith('*')) return false;
|
|
397
|
+
const prefix = ttab.slice(0, -1);
|
|
398
|
+
return tables.some((k) => { const i = k.indexOf('.'); return k.slice(0, i) === tdb && k.slice(i + 1).startsWith(prefix); });
|
|
399
|
+
};
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
// Read with quoted names masked (maskQuoted), so a role or user named like grammar
|
|
402
|
+
// (`ALL ON *`) is never taken for a privilege; the target's names come back unquoted.
|
|
403
|
+
const { masked, names } = maskQuoted(line);
|
|
404
|
+
const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(masked);
|
|
405
|
+
if (!m) continue;
|
|
406
|
+
const target = m[2].replace(/\u0000(\d+)\u0000/g, (_, i) => names[Number(i)]).replace(/`/g, '');
|
|
407
|
+
// Global access rights print as `ON *` (CREATE USER, ROLE ADMIN) or `ON *.*` (ALL).
|
|
408
|
+
if ((target === '*' || target === '*.*') && /\bALL\b|ACCESS MANAGEMENT|CREATE USER|ROLE ADMIN/.test(m[1])) admin = true;
|
|
409
|
+
if (!/\bSELECT\b|\bALL\b/.test(m[1])) continue;
|
|
410
|
+
if (covers(target)) on.add(target);
|
|
411
|
+
}
|
|
412
|
+
return { on, admin };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One SHOW GRANTS line with each backtick-quoted name masked (\u0000<i>\u0000), so its structure
|
|
417
|
+
* (ON, TO, the commas) is read from what is outside the quotes only; the names come back
|
|
418
|
+
* unescaped, as ClickHouse escapes them (\\, \`, \n, \t, …).
|
|
419
|
+
*/
|
|
420
|
+
function maskQuoted(line) {
|
|
421
|
+
const names = [];
|
|
422
|
+
const ESC = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', 0: '\0' };
|
|
423
|
+
const masked = String(line).replace(/`((?:[^`\\]|\\.)*)`/g, (_, body) => {
|
|
424
|
+
names.push(body.replace(/\\(.)/g, (m, c) => (c in ESC ? ESC[c] : c)));
|
|
425
|
+
return `\u0000${names.length - 1}\u0000`;
|
|
426
|
+
});
|
|
427
|
+
return { masked, names };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The roles one SHOW GRANTS listing grants (`GRANT r1, `r 2` TO u [WITH ADMIN OPTION]`), unquoted.
|
|
432
|
+
* A role's privileges are listed under the role, not under its grantee: a reader reaches whatever
|
|
433
|
+
* the roles granted to them reach, and the roles granted to those roles.
|
|
434
|
+
*/
|
|
435
|
+
function rolesGranted(lines) {
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const line of lines) {
|
|
438
|
+
const { masked, names } = maskQuoted(line);
|
|
439
|
+
if (/\sON\s/.test(masked)) continue;
|
|
440
|
+
const m = /^GRANT\s+(.+?)\s+TO\s+/.exec(masked);
|
|
441
|
+
if (!m) continue;
|
|
442
|
+
for (const part of m[1].split(',')) {
|
|
443
|
+
const p = part.trim();
|
|
444
|
+
const q = /^\u0000(\d+)\u0000$/.exec(p);
|
|
445
|
+
const name = q ? names[Number(q[1])] : p;
|
|
446
|
+
if (name) out.push(name);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Where one recorded share stands, from the grants ClickHouse holds (`on`, this member's targets
|
|
454
|
+
* only), the user's policies on the schema-3 tables (`policies`: a Set of table names), and
|
|
455
|
+
* whether an earlier run started moving it (`started`). Returns { apply: true } or { diff }.
|
|
456
|
+
* Only two states proceed without a started mark: exactly the 0.18 share memhouse records, or
|
|
457
|
+
* exactly the schema-3 share with the filters its scope needs. Anything else is a difference,
|
|
458
|
+
* and the caller changes nothing.
|
|
459
|
+
*/
|
|
460
|
+
function classifyShare({ rec, on, policies, started, targets }) {
|
|
461
|
+
const set = (a) => [...a].sort().join(', ');
|
|
462
|
+
// A revoked record with grants still in place is a difference too, never a pass.
|
|
463
|
+
if (!rec.granted) return on.size ? { diff: `recorded as revoked, but holds ${set(on)}` } : { skip: true };
|
|
464
|
+
const same = (a, b) => a.length === b.size && a.every((x) => b.has(x));
|
|
465
|
+
const before = rec.scopeRaw ? targets.before.scoped : targets.before.full;
|
|
466
|
+
const allowed = new Set([...before, ...targets.after]);
|
|
467
|
+
const extra = [...on].filter((x) => !allowed.has(x));
|
|
468
|
+
if (extra.length) return { diff: `holds ${set(extra)} beyond what memhouse recorded (${rec.scopeRaw ? `scoped: ${rec.scopeRaw}` : 'full'})` };
|
|
469
|
+
if (!on.size) return { diff: 'recorded as granted, but holds nothing on these tables' };
|
|
470
|
+
// A scoped reader holding a schema-3 table must hold its filter: the move writes every filter
|
|
471
|
+
// before any grant, and a 0.18 scoped share's filter on <m>_sessions stays with that name.
|
|
472
|
+
const unfiltered = V3_TABLES.filter((t, i) => on.has(targets.after[i]) && !policies.has(t));
|
|
473
|
+
if (rec.scopeRaw && unfiltered.length) return { diff: `reads ${unfiltered.join(', ')} without the filter its scope (${rec.scopeRaw}) needs` };
|
|
474
|
+
if (started) {
|
|
475
|
+
// Only states the move itself passes through: scoped, any mix of the rooms and the filtered
|
|
476
|
+
// tables (checked above); full, the wildcard alone, or some of the tables without it. (A full
|
|
477
|
+
// move stopped between taking back the wildcard and the first grant holds nothing: that was
|
|
478
|
+
// refused above — settled by hand, never re-granted on a guess.)
|
|
479
|
+
if (!rec.scopeRaw && on.has(targets.wildcard) && on.size > 1) return { diff: `holds ${set(on)}: not a state the move passes through` };
|
|
480
|
+
return { apply: true, resume: true };
|
|
481
|
+
}
|
|
482
|
+
if (same(before, on)) return { apply: true };
|
|
483
|
+
if (same(targets.after, on)) {
|
|
484
|
+
const filtered = V3_TABLES.filter((t) => policies.has(t));
|
|
485
|
+
if (!rec.scopeRaw && filtered.length) return { diff: `recorded as a full share, but ${filtered.join(', ')} still carry a filter` };
|
|
486
|
+
return { apply: true };
|
|
487
|
+
}
|
|
488
|
+
return { diff: on.size ? `holds ${set(on)}, which is neither the recorded 0.18 share nor its schema-3 form` : 'recorded as granted, but holds nothing on these tables' };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The statements that move one recorded share onto schema 3: the schema-3 share itself
|
|
493
|
+
* (shareV3Plan: filters, then grants), then, once that is in place, the 0.18 grants it replaces —
|
|
494
|
+
* the rooms in <db> (never a name schema 3 uses too: <m>_sessions IS the new share) and the rooms
|
|
495
|
+
* renamed into the archive — and the 0.18 filters, each by exact name.
|
|
496
|
+
*/
|
|
497
|
+
function moveSharePlan({ db, member, user, scope, legacy, holdsWildcard, sqlStr, parseSessionRef }) {
|
|
498
|
+
const NEW = V3_TABLES.map((t) => `${db}.${member}_${t}`);
|
|
499
|
+
const oldRooms = [...LEGACY_ROOMS.map((r) => `${db}.${member}_${r}`), ...Object.values(legacy || {})].filter((x) => !NEW.includes(x));
|
|
500
|
+
const share = shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef });
|
|
501
|
+
// A 0.18 full share's wildcard goes before the table grants: in ClickHouse a REVOKE on `m_*`
|
|
502
|
+
// subtracts every table it matches, the ones just granted included.
|
|
503
|
+
if (holdsWildcard) share.splice(share.findIndex((x) => x.phase === 'grant'), 0, wildcardRevoke({ db, member, user }));
|
|
504
|
+
return [
|
|
505
|
+
...share,
|
|
506
|
+
...oldRooms.map((fq) => ({ phase: 'clear', sql: `REVOKE SELECT ON ${qual(fq)} FROM ${ident(user)}` })),
|
|
507
|
+
...legacyPolicyDrops({ db, member, user, legacy, keep: NEW }),
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* What `--v3-shares` decides on, read from the house: the recorded shares, the runs that started
|
|
513
|
+
* and did not finish, who holds what on this member's tables, and the filters on its schema-3
|
|
514
|
+
* tables. `q.rows(sql)` runs one query. Any read that fails REJECTS, naming what could not be
|
|
515
|
+
* read: nothing unknown is ever taken for nothing.
|
|
516
|
+
*/
|
|
517
|
+
async function readLiveShares(q, { db, member, meta, targets, sqlStr }) {
|
|
518
|
+
const M = qual(meta);
|
|
519
|
+
const read = async (what, sql) => {
|
|
520
|
+
try { return await q.rows(sql); } catch (e) { throw new Error(`could not read ${what}: ${String(e && e.message || e).split('\n')[0]}`); }
|
|
521
|
+
};
|
|
522
|
+
const recs = await read(meta, `SELECT substring(key, 7) AS user, argMax(value, updated_at) AS value FROM ${M}
|
|
523
|
+
WHERE key LIKE 'share:%' GROUP BY key ORDER BY key`);
|
|
524
|
+
// An earlier run that started moving a share and did not finish leaves 'started' here.
|
|
525
|
+
const marks = await read(meta, `SELECT substring(key, 9) AS user, argMax(value, updated_at) AS value FROM ${M}
|
|
526
|
+
WHERE key LIKE 'v3share:%' GROUP BY key`);
|
|
527
|
+
const users = (await read('system.users', 'SELECT name FROM system.users ORDER BY name')).map((r) => r.name).filter((u) => u !== member);
|
|
528
|
+
const live = new Map(); const admins = [];
|
|
529
|
+
// What a reader reaches through a role (and a role granted to that role), by role: memhouse
|
|
530
|
+
// never shares through a role, so any of it on this member is a grant it did not record.
|
|
531
|
+
const viaRole = new Map();
|
|
532
|
+
const roleLines = new Map();
|
|
533
|
+
const linesOfRole = async (r) => {
|
|
534
|
+
if (!roleLines.has(r)) roleLines.set(r, (await read(`the grants of role '${r}'`, `SHOW GRANTS FOR ${ident(r)}`)).map((x) => Object.values(x)[0]));
|
|
535
|
+
return roleLines.get(r);
|
|
536
|
+
};
|
|
537
|
+
for (const u of users) {
|
|
538
|
+
const text = (await read(`the grants of '${u}'`, `SHOW GRANTS FOR ${ident(u)}`)).map((r) => Object.values(r)[0]);
|
|
539
|
+
const { on, admin } = selectTargets(text, targets.known);
|
|
540
|
+
const { via, admin: roleAdmin } = await reachThroughRoles(text, targets.known, linesOfRole);
|
|
541
|
+
// An administrator reads everything by a global grant, which is no share: only that global
|
|
542
|
+
// grant is set aside, however it reached them (directly or through any role). Anything else
|
|
543
|
+
// they hold on this member counts as anyone's would.
|
|
544
|
+
if (admin || roleAdmin) {
|
|
545
|
+
admins.push(u);
|
|
546
|
+
for (const set of [on, ...via.map((v) => v.on)]) { set.delete('*.*'); set.delete('*'); }
|
|
547
|
+
}
|
|
548
|
+
if (on.size) live.set(u, on);
|
|
549
|
+
const kept = via.filter((v) => v.on.size);
|
|
550
|
+
if (kept.length) viaRole.set(u, kept);
|
|
551
|
+
}
|
|
552
|
+
const tables = (await read('the house\'s tables', `SELECT name FROM system.tables WHERE database = ${sqlStr(db)}`)).map((r) => r.name);
|
|
553
|
+
const pols = await read('system.row_policies', `SELECT short_name, table FROM system.row_policies
|
|
554
|
+
WHERE database = ${sqlStr(db)} AND table IN (${V3_TABLES.map((t) => sqlStr(`${member}_${t}`)).join(', ')})`);
|
|
555
|
+
return {
|
|
556
|
+
recorded: new Map(recs.map((r) => [r.user, parseShareRecord(r.value)])),
|
|
557
|
+
started: new Set(marks.filter((r) => /^started\b/.test(String(r.value))).map((r) => r.user)),
|
|
558
|
+
live, viaRole, admins, collisions: memberCollisions(member, tables),
|
|
559
|
+
policiesOf: (u) => new Set(V3_TABLES.filter((t) => pols.some((r) => r.short_name === policyName(u, t) && r.table === `${member}_${t}`))),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* `memhouse members`: who holds what in the house and in its capture archive, directly or through a
|
|
565
|
+
* role (and a role granted to that role). `membersGrantsSQL` reads the grants of users and roles,
|
|
566
|
+
* MEMBERS_ROLE_GRANTS_SQL who holds which role, and `membersAccess` puts them together per user. A
|
|
567
|
+
* grant in the house is listed by table (`alice_*` for a wildcard), one in the archive by its full
|
|
568
|
+
* name (`mem_archive.alice_session_units`, `mem_archive.*`); one held through a role says so.
|
|
569
|
+
* `hasWild`: the server's system.grants has `is_wildcard` (26.x); before it, a wildcard grant's row
|
|
570
|
+
* carries only the bare prefix (`alice_`), so the trailing underscore is the marker.
|
|
571
|
+
*/
|
|
572
|
+
function membersGrantsSQL({ db, archive, hasWild, sqlStr }) {
|
|
573
|
+
const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
|
|
574
|
+
return `SELECT user_name, role_name, database, table, ${star} AS star
|
|
575
|
+
FROM system.grants WHERE database IN (${sqlStr(db)}, ${sqlStr(archive)})`;
|
|
576
|
+
}
|
|
577
|
+
const MEMBERS_ROLE_GRANTS_SQL = 'SELECT user_name, role_name, granted_role_name FROM system.role_grants';
|
|
578
|
+
|
|
579
|
+
function membersAccess({ grants, roleGrants = [], db }) {
|
|
580
|
+
const label = (g) => (g.database === db ? (g.table == null ? null : `${g.table}${g.star || ''}`)
|
|
581
|
+
: `${g.database}.${g.table == null ? '*' : `${g.table}${g.star || ''}`}`);
|
|
582
|
+
const byUser = new Map(); const byRole = new Map();
|
|
583
|
+
const add = (m, k, g) => {
|
|
584
|
+
if (!m.has(k)) m.set(k, { dbWide: false, tables: new Set() });
|
|
585
|
+
const e = m.get(k);
|
|
586
|
+
if (g.database === db && g.table == null) e.dbWide = true;
|
|
587
|
+
const n = label(g); if (n) e.tables.add(n);
|
|
588
|
+
};
|
|
589
|
+
for (const g of grants) { if (g.user_name != null) add(byUser, g.user_name, g); else if (g.role_name != null) add(byRole, g.role_name, g); }
|
|
590
|
+
const rolesOf = new Map();
|
|
591
|
+
for (const r of roleGrants) {
|
|
592
|
+
const k = r.user_name != null ? `u:${r.user_name}` : `r:${r.role_name}`;
|
|
593
|
+
if (!rolesOf.has(k)) rolesOf.set(k, []);
|
|
594
|
+
rolesOf.get(k).push(r.granted_role_name);
|
|
595
|
+
}
|
|
596
|
+
const users = new Set([...byUser.keys(), ...roleGrants.filter((r) => r.user_name != null).map((r) => r.user_name)]);
|
|
597
|
+
const out = [];
|
|
598
|
+
for (const u of [...users].sort()) {
|
|
599
|
+
const own = byUser.get(u) || { dbWide: false, tables: new Set() };
|
|
600
|
+
let dbWide = own.dbWide; const tables = [...own.tables].sort();
|
|
601
|
+
const seen = new Set(); const queue = [...(rolesOf.get(`u:${u}`) || [])];
|
|
602
|
+
while (queue.length) {
|
|
603
|
+
const r = queue.shift();
|
|
604
|
+
if (seen.has(r)) continue;
|
|
605
|
+
seen.add(r);
|
|
606
|
+
const e = byRole.get(r);
|
|
607
|
+
if (e) {
|
|
608
|
+
if (e.dbWide) { dbWide = true; tables.push(`the whole database (through role ${r})`); }
|
|
609
|
+
for (const t of [...e.tables].sort()) tables.push(`${t} (through role ${r})`);
|
|
610
|
+
}
|
|
611
|
+
queue.push(...(rolesOf.get(`r:${r}`) || []));
|
|
612
|
+
}
|
|
613
|
+
if (dbWide || tables.length) out.push({ u, db_wide: dbWide ? 1 : 0, tables: tables.join(', '), list: tables });
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* What a user reaches of one member (`known`, memberTargets) through the roles their grant lines
|
|
620
|
+
* name, and the roles granted to those roles: [{ role, on }], cycles stopped. `linesOfRole(r)`
|
|
621
|
+
* returns a role's SHOW GRANTS lines, and rejects when they cannot be read. `admin`: a role holds
|
|
622
|
+
* an administrator's global grant (the caller sets that grant aside).
|
|
623
|
+
*/
|
|
624
|
+
async function reachThroughRoles(grantLines, known, linesOfRole) {
|
|
625
|
+
const via = []; let admin = false;
|
|
626
|
+
const seen = new Set(); const queue = rolesGranted(grantLines);
|
|
627
|
+
while (queue.length) {
|
|
628
|
+
const r = queue.shift();
|
|
629
|
+
if (seen.has(r)) continue;
|
|
630
|
+
seen.add(r);
|
|
631
|
+
const rt = await linesOfRole(r);
|
|
632
|
+
const got = selectTargets(rt, known);
|
|
633
|
+
if (got.admin) admin = true;
|
|
634
|
+
if (got.on.size) via.push({ role: r, on: got.on });
|
|
635
|
+
queue.push(...rolesGranted(rt));
|
|
636
|
+
}
|
|
637
|
+
return { via, admin };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* `memhouse share <user> --revoke`, after the REVOKE: what the reader still reads of the member
|
|
642
|
+
* through a role. A REVOKE from the reader leaves a role's privileges, so the revoke is not done
|
|
643
|
+
* while any remain; nor while they cannot be read (`unreadable`: why). Returns the lines to print
|
|
644
|
+
* and whether the revoke may say so.
|
|
645
|
+
*/
|
|
646
|
+
function revokeRoleReport({ user, via, direct = [], collision = [], collisionOther = [], unlisted = [], unreadable = null }) {
|
|
647
|
+
if (unreadable) return { done: false, lines: [`'${user}' had their own grants withdrawn, but whether they still read your house through a role could not be checked: ${unreadable}. Ask the admin to check the roles '${user}' holds.`] };
|
|
648
|
+
const lines = [
|
|
649
|
+
...(collision.length ? [`'${user}' still reaches your tables through the member grant ${collision.join(', ')}: your name and theirs collide (one is the other's plus an underscore), so no revoke closes it. Do not revoke that grant, which is their own house; see docs/guides/troubleshooting.md, "Two member names that collide"`] : []),
|
|
650
|
+
...collisionOther.map(({ target, member }) => `'${user}' still reaches your tables through ${target}, member '${member}''s grant to them: your name and '${member}''s collide (one is the other's plus an underscore), so that grant reaches your tables too. It is '${member}''s grant, and in a house with colliding names an admin takes it back (docs/guides/troubleshooting.md, "Two member names that collide")`),
|
|
651
|
+
...(unlisted.length ? [`'${user}' still holds ${unlisted.join(', ')}: the house's tables or users could not be listed, so whether that is another member's own house is unknown. An admin checks before revoking it`] : []),
|
|
652
|
+
...(direct.length ? [`'${user}' still holds ${direct.join(', ')} by a grant memhouse did not make: an admin revokes it`] : []),
|
|
653
|
+
...via.map((v) => `'${user}' can still read ${[...v.on].join(', ')} through role '${v.role}': revoke the role's grant, or remove '${user}' from '${v.role}' (an admin does either)`),
|
|
654
|
+
];
|
|
655
|
+
return { done: !lines.length, lines };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The check a revoke ends with, once the reader's own grants are withdrawn: what they still read of
|
|
660
|
+
* the member (`known`, memberTargets). Their remaining direct grants (anything but the 0.18
|
|
661
|
+
* wildcard, which the caller handles), and what their roles reach, nested ones included. Nothing
|
|
662
|
+
* is set aside for an administrator: a REVOKE from the reader leaves a role's ALL ON *.* in place,
|
|
663
|
+
* and a direct administrator still reads everything, so either is reported. `read(name)` returns a
|
|
664
|
+
* user's or a role's SHOW GRANTS lines; a read that fails makes the check `unreadable`. `db` and
|
|
665
|
+
* `collisions` (wildcardOnRevoke's `colliding`: by table and by user; null when either could not be
|
|
666
|
+
* listed): a reader whose own member grant, `<db>.<their name>_*`, reaches
|
|
667
|
+
* this member's tables is a name collision, said as one, never "a grant memhouse did not make".
|
|
668
|
+
*/
|
|
669
|
+
async function revokeCheck({ read, user, known, wildcard, db = null, collisions = [] }) {
|
|
670
|
+
try {
|
|
671
|
+
const lines = await read(user);
|
|
672
|
+
// The reader's own member grant (`<db>.<reader>_*`, their own house) and another colliding
|
|
673
|
+
// member's wildcard (that member's grant to them) reach this member only through the collision.
|
|
674
|
+
// collisions null: the house's tables or users could not be listed, so a member-shaped wildcard
|
|
675
|
+
// (`<db>.<name>_*`) may be another member's own house, and is never called a stray grant.
|
|
676
|
+
const cs = collisions || [];
|
|
677
|
+
const own = cs.includes(user) ? `${db}.${user}_*` : null;
|
|
678
|
+
const others = new Map(cs.filter((c) => c !== user).map((c) => [`${db}.${c}_*`, c]));
|
|
679
|
+
const held = [...selectTargets(lines, known).on].filter((t) => t !== wildcard);
|
|
680
|
+
const memberShaped = (t) => collisions === null && t.startsWith(`${db}.`) && /^[^.*]+_\*$/.test(t.slice(db.length + 1));
|
|
681
|
+
const { via } = await reachThroughRoles(lines, known, read);
|
|
682
|
+
return revokeRoleReport({ user, via: via.filter((v) => v.on.size),
|
|
683
|
+
unlisted: held.filter(memberShaped),
|
|
684
|
+
direct: held.filter((t) => t !== own && !others.has(t) && !memberShaped(t)),
|
|
685
|
+
collision: held.filter((t) => t === own),
|
|
686
|
+
collisionOther: held.filter((t) => others.has(t)).map((t) => ({ target: t, member: others.get(t) })) });
|
|
687
|
+
} catch (e) {
|
|
688
|
+
return revokeRoleReport({ user, via: [], unreadable: String(e && e.message || e).split('\n')[0] });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** --v3-shares: each reader's role-held access to the member, as the difference that stops the move. */
|
|
693
|
+
function roleDifferences(viaRole) {
|
|
694
|
+
return [...viaRole].map(([u, via]) => `${u}: holds ${via.map((v) => `${[...v.on].join(', ')} through role '${v.role}'`).join('; ')} — memhouse never shares through a role; settle it by hand`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The 0.18 filters of one user on one member, by exact name and table, wherever the rooms are now. */
|
|
698
|
+
function legacyPolicyDrops({ db, member, user, legacy, keep }) {
|
|
699
|
+
assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
|
|
700
|
+
const q2 = (fq) => fq.split('.').map(ident).join('.');
|
|
701
|
+
const out = [];
|
|
702
|
+
for (const r of LEGACY_ROOMS) {
|
|
703
|
+
const name = ident(policyName(user, r));
|
|
704
|
+
for (const fq of [`${db}.${member}_${r}`, legacy && legacy[r]].filter(Boolean)) {
|
|
705
|
+
if (keep && keep.includes(fq)) continue;
|
|
706
|
+
out.push({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${name} ON ${q2(fq)}` });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** A meta share record's value -> { granted, scopeRaw } ('granted 2026-10-01 scope=project=x', 'revoked 2026-10-02'). */
|
|
713
|
+
function parseShareRecord(value) {
|
|
714
|
+
const v = String(value || '');
|
|
715
|
+
const m = /^granted\b[^]*?(?:\sscope=(.+))?$/.exec(v);
|
|
716
|
+
return m ? { granted: true, scopeRaw: m[1] ? m[1].trim() : null } : { granted: false, scopeRaw: null };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* One account's grants from membersAccess, told apart for a reader: its own tables, the tables
|
|
721
|
+
* of each member it reads (a share), and anything else (a hand-made grant, a wildcard reaching
|
|
722
|
+
* past its own name). A table belongs to the member whose name is left after the longest
|
|
723
|
+
* memhouse table name (MEMBER_TABLES) is taken off its end. `policies` are the row policies the
|
|
724
|
+
* reading credential can list ([{ short_name, table }]); null when it cannot, and then a share's
|
|
725
|
+
* scope is unknown.
|
|
726
|
+
* @returns {{ own: string[], shares: {from:string, tables:string[], scoped:boolean|null}[], other: string[] }}
|
|
727
|
+
*/
|
|
728
|
+
function describeAccess({ u, list }, { policies = null } = {}) {
|
|
729
|
+
const own = []; const other = []; const shared = new Map();
|
|
730
|
+
for (const label of list || []) {
|
|
731
|
+
const bare = String(label).replace(/ \(through role [^)]*\)$/, '');
|
|
732
|
+
const name = bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare;
|
|
733
|
+
if (name === `${u}_*` || name === `${u}_`) { own.push(label); continue; }
|
|
734
|
+
const type = [...MEMBER_TABLES, 'session_units', 'session_sources'].filter((t) => name.endsWith(`_${t}`) && name.length > t.length + 1).sort((a, b) => b.length - a.length)[0];
|
|
735
|
+
const owner = type ? name.slice(0, -(type.length + 1)) : null;
|
|
736
|
+
if (owner === u) own.push(label);
|
|
737
|
+
else if (owner && !bare.includes('.') && V3_TABLES.concat(LEGACY_ROOMS).includes(type)) {
|
|
738
|
+
if (!shared.has(owner)) shared.set(owner, []);
|
|
739
|
+
shared.get(owner).push(label);
|
|
740
|
+
} else other.push(label);
|
|
741
|
+
}
|
|
742
|
+
const shares = [...shared.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([from, tables]) => ({
|
|
743
|
+
from, tables,
|
|
744
|
+
scoped: policies ? policies.some((p) => String(p.short_name).startsWith(`mh_share_${u}_`) && String(p.table).startsWith(`${from}_`)) : null,
|
|
745
|
+
}));
|
|
746
|
+
return { own, shares, other };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
module.exports = { SCOPE_COLUMNS, parseScope, scopePredicate, policyName, V3_TABLES, scopePredicateV3, shareV3Plan, revokeV3Plan, parseShareRecord,
|
|
750
|
+
assertName, ident, qual, memberTargets, moveSharePlan, wildcardRevoke, memberCollisions, wildcardOnRevoke, holdsMemberGrant, collidingUsers, ownPolicies, showPoliciesRevokes, wildcardReachText, wildcardAdminLine, shareGate, selectTargets, rolesGranted, reachThroughRoles, revokeRoleReport, revokeCheck, classifyShare, legacyPolicyDrops, readLiveShares, membersGrantsSQL, MEMBERS_ROLE_GRANTS_SQL, membersAccess, describeAccess, roleDifferences, LEGACY_ROOMS };
|