Compare commits
2
Commits
989c15e437
...
069dd4b23f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069dd4b23f | ||
|
|
c982d82dde |
@@ -707,6 +707,25 @@ outside the example's new README:
|
|||||||
|
|
||||||
## Backburner
|
## 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 —
|
- [ ] **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
|
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
|
table. Discarded before completion; would need `GET /file-browser/flatten-audio`, a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { UserRole } from 'officerdb';
|
import type { UserRole } from 'officerdb';
|
||||||
import { updateUser } 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 { provisionSshAccess } from '@@/os-user-ssh';
|
||||||
import { seedShellConfig } from '@@/os-user-shell';
|
import { seedShellConfig } from '@@/os-user-shell';
|
||||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||||
@@ -127,6 +127,19 @@ export async function provisionOsAccount(params: {
|
|||||||
})
|
})
|
||||||
: null;
|
: 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
|
// 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.
|
// it. Only the keys are missing, and that is what the error says.
|
||||||
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
|
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 { chmod, mkdir, mkdtemp, stat, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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
|
// 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
|
// 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: [] });
|
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 = ['.claude', '.pm2', '.config', '.npm', '.opencode', '.cache', '.tmux'];
|
||||||
const OWNER_HOME_PRIVATE_NESTED = ['.local/share', '.local/state'];
|
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.
|
* Close the owner's own dotfiles to the accounts this platform hands shells to.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user