Compare commits
2
Commits
989c15e437
...
069dd4b23f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069dd4b23f | ||
|
|
c982d82dde |
@@ -707,6 +707,25 @@ outside the example's new README:
|
||||
|
||||
## Backburner
|
||||
|
||||
- [ ] **Four foreign keys are over Postgres's 63-character limit and re-create on every `db:push`.**
|
||||
Found 2026-08-19 while pushing `chat_notification_prefs`. Not a blocker and not causing trouble:
|
||||
eight extra statements per push, no prompt, no data touched — the same harmless churn already
|
||||
documented for `pk_music_now_playing`, and recorded here only so the next person reading a push
|
||||
plan knows it is not their change.
|
||||
|
||||
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
|
||||
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
|
||||
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
|
||||
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
|
||||
|
||||
The fix is the one `src/databases/CLAUDE.md` already prescribes and these four missed — declare
|
||||
them with `foreignKey({ name: 'fk_…', … })` under 63 characters, exactly as
|
||||
`fk_user_integrations_server_integration` was. **Renaming a constraint is not a data change**, but
|
||||
it does mean one push where the old name drops and the new one is added, so do it deliberately
|
||||
rather than folded into an unrelated change. Note the two bitcoin-wallet ones live in that
|
||||
plugin's own repository (`plugins/bitcoin-wallet/db/schema.ts`), so this spans two repos and the
|
||||
plugin half only churns on a host where that plugin is installed.
|
||||
|
||||
- [ ] **Music tagging feature** (`/music`, Mp3tag-inspired). v1 was scoped as a two-panel workspace —
|
||||
file browser left, table right, single "Load" context-menu item flattening audio files into the
|
||||
table. Discarded before completion; would need `GET /file-browser/flatten-audio`, a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { updateUser } from 'officerdb';
|
||||
import { ensureOsUser, hardenOwnerHome, osUserHome } from '@@/os-user';
|
||||
import { ensureOsUser, hardenOwnerHome, osUserHome, reassertHomeMask } from '@@/os-user';
|
||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||
import { seedShellConfig } from '@@/os-user-shell';
|
||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||
@@ -127,6 +127,19 @@ export async function provisionOsAccount(params: {
|
||||
})
|
||||
: null;
|
||||
|
||||
// LAST, and it has to be last.
|
||||
//
|
||||
// Every step above that creates a directory and then closes it — `.ssh` at 700, the claude installer's
|
||||
// `.config` and `.local` — recomputes that directory's ACL mask from its new group bits. For 700 that is
|
||||
// `mask::---`, which clamps the platform's own entry to nothing: the grant survives as
|
||||
// `user:pastilhas:rwx #effective:---`, correct-looking and inert.
|
||||
//
|
||||
// Found on a fresh server where an Admin granted files, chat and terminal reached none of them, and the
|
||||
// ACL looked right everywhere anyone checked. Non-fatal — but it is the difference between an account
|
||||
// that works and one that only appears to.
|
||||
const mask = await reassertHomeMask(osUserHome(params.email));
|
||||
if (!mask.ok) console.warn(`[users] ${params.email}: ${mask.error}`);
|
||||
|
||||
// The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
|
||||
// it. Only the keys are missing, and that is what the error says.
|
||||
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { chmod, mkdir, mkdtemp, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv, secureRuntimeSecrets } from './os-user';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv, secureRuntimeSecrets, maskArgvFor } from './os-user';
|
||||
|
||||
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
|
||||
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
|
||||
@@ -243,3 +243,36 @@ describe('secureRuntimeSecrets', () => {
|
||||
expect(await secureRuntimeSecrets(root)).toEqual({ repaired: [], failed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACL mask, after everything else', () => {
|
||||
// The regression: a chmod recomputes the ACL mask from the GROUP bits, so a directory closed to 700
|
||||
// after the platform's grant was set ends up `mask::---`, clamping `user:<service>:rwx` to nothing.
|
||||
// The entry is still there and reads as correct; it just does not grant anything.
|
||||
//
|
||||
// This asserts the shape of the repair rather than the filesystem effect — the effect needs root, an
|
||||
// acl-capable filesystem and a second uid, none of which a unit test has. It was verified live on a
|
||||
// real member's home instead (2026-08-19): READABLE afterwards, and zero `effective:---` entries left.
|
||||
test('re-asserts the mask recursively, and touches nothing else', () => {
|
||||
const argv = maskArgvFor('/data/someone@example.com/home');
|
||||
|
||||
expect(argv).toEqual([
|
||||
'sudo',
|
||||
'-n',
|
||||
'setfacl',
|
||||
'-R',
|
||||
'-m',
|
||||
'm::rwx',
|
||||
'-m',
|
||||
'd:m::rwx',
|
||||
'/data/someone@example.com/home',
|
||||
]);
|
||||
|
||||
// Recursive, because every subdirectory created-then-closed after the original grant has the same
|
||||
// clamped mask — `.ssh`, `.config` and `.local` all did.
|
||||
expect(argv).toContain('-R');
|
||||
// The default mask too, or the next directory the member creates inherits the broken state.
|
||||
expect(argv).toContain('d:m::rwx');
|
||||
// No named entries: those were always right, and re-granting them here would hide it if they were not.
|
||||
expect(argv.some((a: string) => a.startsWith('u:'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -545,6 +545,49 @@ export async function confineUserTree(params: {
|
||||
const OWNER_HOME_PRIVATE = ['.claude', '.pm2', '.config', '.npm', '.opencode', '.cache', '.tmux'];
|
||||
const OWNER_HOME_PRIVATE_NESTED = ['.local/share', '.local/state'];
|
||||
|
||||
/**
|
||||
* The exact command, split out so a test can assert it without root or an acl-capable filesystem.
|
||||
*
|
||||
* `-R` because the same clamp happens to every subdirectory created and closed after the original grant,
|
||||
* not just to the home itself. Only the mask — the named entries were never the problem.
|
||||
*/
|
||||
export const maskArgvFor = (home: string): string[] => [
|
||||
'sudo',
|
||||
'-n',
|
||||
'setfacl',
|
||||
'-R',
|
||||
'-m',
|
||||
'm::rwx',
|
||||
'-m',
|
||||
'd:m::rwx',
|
||||
home,
|
||||
];
|
||||
|
||||
/**
|
||||
* Re-assert the ACL mask across a member's home. **Must run after every other provisioning step.**
|
||||
*
|
||||
* ── The failure this exists for ──
|
||||
*
|
||||
* A POSIX ACL mask is not independent of the mode bits: `chmod` recomputes it from the GROUP bits, and a
|
||||
* directory chmod'd to 700 gets `mask::---`, which clamps every named entry to nothing. The platform's
|
||||
* grant then reads `user:pastilhas:rwx #effective:---` — present, and meaningless.
|
||||
*
|
||||
* `ensureOsUser` already knows this and orders itself correctly (chmod, then setfacl). What it cannot
|
||||
* account for is the steps that come AFTER it, each of which creates a directory and then closes it:
|
||||
* `.ssh` at 700 from the key setup, `.config` and `.local` from the claude installer. Each one inherits
|
||||
* the default ACL at creation and then clamps its own mask a moment later.
|
||||
*
|
||||
* Observed on a fresh server, 2026-08-19: an Admin holding `files`, `chat` and `terminal` reached none of
|
||||
* them. The file browser's `readdir` raised EACCES on a home whose ACL said it could read it, and the
|
||||
* grant looked correct in every place anyone thought to check.
|
||||
*
|
||||
* Only the mask is touched. The named entries are already right — they were never the problem.
|
||||
*/
|
||||
export async function reassertHomeMask(home: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const done = await run(maskArgvFor(home));
|
||||
return done.ok ? { ok: true } : { ok: false, error: `could not re-assert ACL mask on ${home}: ${done.out}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the owner's own dotfiles to the accounts this platform hands shells to.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user