Files
Ten31-Portal/deploy/startos/actions/index.ts
T
Jonathan KirkwoodandClaude Opus 4.8 69f12b0519 0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4):

Default admin credentials (P2): remove the fixed `Ten31` default. First
boot now generates a strong random admin password (secrets.token_urlsafe),
records it 0600 at /data/.admin-password, and surfaces it once via a new
"Show Initial Admin Password" StartOS action (CLI `show-admin-password`).
The stored password is cleared when the admin is reset (CLI reset-password)
or self-changes it (change-password endpoint).

Login hardening (P2): add a per-IP in-memory sliding-window rate limiter
(10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy
argon2 verify when the user is unknown so timing can't enumerate usernames;
keep a single generic 401 for unknown-user and wrong-password.

Hardening (P3): server process now runs unprivileged -- Dockerfile adds
uid 10001 appuser; start.sh (still root) chowns the mounted /data then
drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are
size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the
schedule, capital preview, and batch paths. batch_import no longer returns
raw exception text (generic per-file messages).

Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001,
/data owned 10001 with 0600 secrets; generated admin password retrievable
via CLI and logs in (200); 11th bad login -> 429; admin reset clears the
stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed;
frontend tsc + StartOS bundle clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:09:27 -05:00

426 lines
12 KiB
TypeScript

import { sdk } from '../sdk'
const { InputSpec, Value, Action } = sdk
// Run a ten31portal CLI subcommand inside the service container against the data volume.
async function runCli(effects: any, args: string[], taskName: string) {
const sub = await sdk.SubContainer.of(
effects,
{ imageId: 'main' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
}),
taskName,
)
return sub.exec(
['python3', '-m', 'ten31portal.cli', ...args],
{ env: { TEN31_DB_PATH: '/data/portal.db' } },
30000,
)
}
const errorResult = (message: string) =>
({ version: '1' as const, title: 'Error', message, result: null })
// ============================================
// Action: Create User
// ============================================
const createUserInputSpec = InputSpec.of({
name: Value.text({
name: 'Full Name',
description: 'Display name for the user',
default: '',
required: true,
placeholder: 'Jane Smith',
}),
email: Value.text({
name: 'Email',
description: 'Login email address',
default: '',
required: true,
placeholder: 'jane@ten31.com',
patterns: [
{
regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
description: 'Must be a valid email address',
},
],
}),
username: Value.text({
name: 'Username',
description: 'Login username',
default: '',
required: true,
placeholder: 'jsmith',
}),
password: Value.text({
name: 'Password',
description: 'Initial password (user should change after first login)',
default: '',
required: true,
placeholder: 'minimum 4 characters',
}),
role: Value.select({
name: 'Role',
description:
'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.',
default: 'operations',
values: {
approver: 'Managing Partner',
operations: 'Operations',
fund_admin: 'Fund Admin',
},
}),
})
const createUserAction = Action.withInput(
'create-user',
{
name: 'Create User',
description: 'Add a new staff user account with a role',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
createUserInputSpec,
async () => ({
name: '',
email: '',
username: '',
password: '',
role: 'operations' as const,
}),
async ({ input, effects }) => {
try {
const result = await runCli(
effects,
[
'create-user',
'--name', input.name,
'--username', input.username,
'--email', input.email,
'--role', input.role,
'--password', input.password,
],
'create-user-task',
)
if (result.exitCode !== 0) {
return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`)
}
return {
version: '1' as const,
title: 'User Created',
message: `Created ${input.name} (${input.username}) with role: ${input.role}`,
result: null,
}
} catch (e: any) {
return errorResult(`Failed to create user: ${e.message || e}`)
}
},
)
// ============================================
// Action: Reset Password
// ============================================
const resetPasswordInputSpec = InputSpec.of({
username: Value.text({
name: 'Username',
description: 'Username of the account to reset (e.g. the Admin / Service Admin)',
default: '',
required: true,
placeholder: 'admin',
}),
password: Value.text({
name: 'New Password',
description: 'The new password for this account',
default: '',
required: true,
placeholder: 'minimum 4 characters',
}),
})
const resetPasswordAction = Action.withInput(
'reset-password',
{
name: 'Reset Password',
description: "Reset any user's password (including the Admin / Service Admin)",
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
resetPasswordInputSpec,
async () => ({ username: '', password: '' }),
async ({ input, effects }) => {
try {
const result = await runCli(
effects,
['reset-password', '--username', input.username, '--password', input.password],
'reset-password-task',
)
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to reset password')
}
return {
version: '1' as const,
title: 'Password Reset',
message: `Password reset for ${input.username}. Their login is enabled.`,
result: null,
}
} catch (e: any) {
return errorResult(`Failed to reset password: ${e.message || e}`)
}
},
)
// ============================================
// Action: List Users
// ============================================
const listUsersAction = Action.withoutInput(
'list-users',
{
name: 'List Users',
description: 'Show every user account and role',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
async ({ effects }) => {
try {
const result = await runCli(effects, ['list-users'], 'list-users-task')
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to list users')
}
return {
version: '1' as const,
title: 'Users',
message: 'The Service Admin cannot be deleted.',
result: {
type: 'single' as const,
value: result.stdout?.toString() || 'No users.',
copyable: true,
qr: false,
masked: false,
},
}
} catch (e: any) {
return errorResult(`Failed to list users: ${e.message || e}`)
}
},
)
// ============================================
// Action: Show Initial Admin Password
// ============================================
const showAdminPasswordAction = Action.withoutInput(
'show-admin-password',
{
name: 'Show Initial Admin Password',
description:
'Reveal the randomly-generated admin password created on first boot. Sign in with it, then change your password — after which this no longer shows it.',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
async ({ effects }) => {
try {
const result = await runCli(effects, ['show-admin-password'], 'show-admin-password-task')
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to read the admin password')
}
return {
version: '1' as const,
title: 'Initial Admin Password',
message: 'Sign in as "admin" with this password, then change it from the portal.',
result: {
type: 'single' as const,
value: (result.stdout?.toString() || '').trim() || 'No stored password.',
copyable: true,
qr: false,
masked: true,
},
}
} catch (e: any) {
return errorResult(`Failed to read the admin password: ${e.message || e}`)
}
},
)
// ============================================
// Action: Delete User
// ============================================
const deleteUserInputSpec = InputSpec.of({
username: Value.text({
name: 'Username',
description: 'Username of the account to delete (use List Users to see them)',
default: '',
required: true,
placeholder: 'jsmith',
}),
})
const deleteUserAction = Action.withInput(
'delete-user',
{
name: 'Delete User',
description: 'Permanently delete a user account',
warning:
'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.',
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
deleteUserInputSpec,
async () => ({ username: '' }),
async ({ input, effects }) => {
try {
const result = await runCli(
effects,
['delete-user', '--username', input.username],
'delete-user-task',
)
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to delete user')
}
return {
version: '1' as const,
title: 'User Deleted',
message: result.stdout?.toString() || `Deleted ${input.username}.`,
result: null,
}
} catch (e: any) {
return errorResult(`Failed to delete user: ${e.message || e}`)
}
},
)
// ============================================
// Action: Fix Duplicate Holdings
// ============================================
const dedupeAction = Action.withoutInput(
'dedupe-holdings',
{
name: 'Fix Duplicate Holdings',
description:
'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
async ({ effects }) => {
try {
const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task')
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates')
}
return {
version: '1' as const,
title: 'Duplicates Cleaned Up',
message: result.stdout?.toString() || 'Done.',
result: null,
}
} catch (e: any) {
return errorResult(`Failed to clean up duplicates: ${e.message || e}`)
}
},
)
// ============================================
// Action: List Funds
// ============================================
const listFundsAction = Action.withoutInput(
'list-funds',
{
name: 'List Funds',
description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)',
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
async ({ effects }) => {
try {
const result = await runCli(effects, ['list-funds'], 'list-funds-task')
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to list funds')
}
return {
version: '1' as const,
title: 'Funds',
message: null,
result: {
type: 'single' as const,
value: result.stdout?.toString() || 'No funds.',
copyable: true,
qr: false,
masked: false,
},
}
} catch (e: any) {
return errorResult(`Failed to list funds: ${e.message || e}`)
}
},
)
// ============================================
// Action: Reset Fund Holdings
// ============================================
const resetHoldingsInputSpec = InputSpec.of({
name: Value.text({
name: 'Fund Name',
description: 'Exact name of the fund to clear (see List Funds)',
default: '',
required: true,
placeholder: 'Low Time Preference Fund III, LP',
}),
})
const resetHoldingsAction = Action.withInput(
'reset-holdings',
{
name: 'Reset Fund Holdings',
description:
'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.',
warning:
'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.',
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
},
resetHoldingsInputSpec,
async () => ({ name: '' }),
async ({ input, effects }) => {
try {
const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task')
if (result.exitCode !== 0) {
return errorResult(result.stderr?.toString() || 'Failed to reset fund')
}
return {
version: '1' as const,
title: 'Fund Cleared',
message: result.stdout?.toString() || `Cleared ${input.name}.`,
result: null,
}
} catch (e: any) {
return errorResult(`Failed to reset fund: ${e.message || e}`)
}
},
)
export const actions = sdk.Actions.of()
.addAction(createUserAction)
.addAction(resetPasswordAction)
.addAction(showAdminPasswordAction)
.addAction(listUsersAction)
.addAction(deleteUserAction)
.addAction(dedupeAction)
.addAction(listFundsAction)
.addAction(resetHoldingsAction)