fix: require explicit pilot tenant and reject development placeholder

This commit is contained in:
Ochenstarik 2026-09-26 16:27:19 +07:00
parent 23a6f5ca80
commit df39565b68
10 changed files with 70 additions and 8 deletions

View file

@ -11,5 +11,6 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000
PLATFORM_JWT_ISSUER=https://platform.example.invalid
PLATFORM_JWT_AUDIENCE=krona-staff
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
STANDALONE_TENANT_ID=01J00000000000000000000000
# Set this to the organization's tenant ID from Krona Business Platform.
STANDALONE_TENANT_ID=
STANDALONE_ACTOR_ID=01J00000000000000000000001

View file

@ -8,6 +8,8 @@
```bash
cp .env.example .env
# Перед следующей командой заполните STANDALONE_TENANT_ID в .env:
# используйте ULID организации из Krona Business Platform, не ID сотрудника.
docker compose --profile tools run --rm migrate
docker compose up --build app postgres
```

View file

@ -13,7 +13,7 @@ services:
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:-01J00000000000000000000000}
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID}
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
ports: ['8080:8080']
depends_on:

View file

@ -5,7 +5,20 @@ cd "$(dirname "$0")"
umask 077
mkdir -p secrets
validate_tenant() {
: "${STANDALONE_TENANT_ID:?Set STANDALONE_TENANT_ID to the platform organization ID before setup}"
if ! printf '%s\n' "$STANDALONE_TENANT_ID" | LC_ALL=C grep -Eq '^[0-9A-HJKMNP-TV-Z]{26}$'; then
echo 'STANDALONE_TENANT_ID must be a 26-character ULID' >&2
exit 1
fi
if [ "$STANDALONE_TENANT_ID" = '01J00000000000000000000000' ]; then
echo 'The development placeholder cannot be used for a pilot organization' >&2
exit 1
fi
}
if [ ! -f .env ]; then
validate_tenant
migration_password="$(openssl rand -hex 32)"
app_password="$(openssl rand -hex 32)"
manager_token="$(openssl rand -hex 32)"
@ -17,7 +30,7 @@ if [ ! -f .env ]; then
printf 'STANDALONE_MANAGER_TOKEN=%s\n' "$manager_token"
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
printf 'STANDALONE_TENANT_ID=01J00000000000000000000000\n'
printf 'STANDALONE_TENANT_ID=%s\n' "$STANDALONE_TENANT_ID"
printf 'STANDALONE_ACTOR_ID=01J00000000000000000000001\n'
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
} > .env
@ -25,6 +38,9 @@ if [ ! -f .env ]; then
printf '%s\n' "$terminal_token" > secrets/terminal.token
fi
. ./.env
validate_tenant
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
docker run --rm \
-v "$(cd ../.. && pwd):/app:ro" \
@ -34,6 +50,7 @@ if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
fi
. ./.env
validate_tenant
if [ ! -f secrets/staff.pfx ]; then
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
-keyout secrets/staff.key -out secrets/staff.crt \

View file

@ -1,5 +1,7 @@
# Развёртывание
После копирования `.env.example` обязательно заполните `STANDALONE_TENANT_ID` идентификатором организации (ULID) из Krona Business Platform. Значения по умолчанию нет; прежняя заглушка `01J00000000000000000000000` не подходит для Compose или пилота. Для первого запуска `deploy/pilot/setup.sh` передайте этот ID через переменную окружения: установщик проверит его и сохранит в своём `.env`. При повторном запуске проверяется существующий `.env`. Для уже заполненного пилота нельзя просто заменить ID: сначала нужна резервная копия, проверка восстановления и перенос tenant-данных.
Локально скопируйте `.env.example` в `.env` и выполните `docker compose up --build`. Compose последовательно проверит PostgreSQL, создаст или обновит отдельную роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, выполнит миграции ролью `krona_migrator` и только затем запустит приложение. Runtime-контейнер не получает пароль или URL мигратора. Redis и object storage не включаются до появления реального сценария очередей или файлов.
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.

View file

@ -1,5 +1,8 @@
import { z } from 'zod';
const PLACEHOLDER_TENANT_ID = '01J00000000000000000000000';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
@ -23,7 +26,9 @@ const configSchema = z.object({
PLATFORM_JWT_ISSUER: optionalUrl(),
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
PLATFORM_JWKS_URL: optionalUrl(),
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
STANDALONE_TENANT_ID: z
.string({ error: 'STANDALONE_TENANT_ID is required and must be a ULID' })
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
@ -42,6 +47,14 @@ export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const config = configSchema.parse(env);
if (
config.STANDALONE_TENANT_ID === PLACEHOLDER_TENANT_ID &&
!(config.NODE_ENV === 'development' && LOOPBACK_HOSTS.has(config.HOST.toLowerCase()))
) {
throw new Error(
'STANDALONE_TENANT_ID uses the development placeholder, which is allowed only in development on loopback',
);
}
if (Boolean(config.HTTPS_PFX_PATH) !== Boolean(config.HTTPS_PFX_PASSPHRASE)) {
throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together');
}

View file

@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
const config = loadConfig({
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
LOG_LEVEL: 'silent',
NODE_ENV: 'test',

View file

@ -25,7 +25,7 @@ function assertOpenApiDocument(value: unknown): asserts value is OpenApiDocument
const config = loadConfig({
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
LOG_LEVEL: 'silent',
NODE_ENV: 'test',

View file

@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
const base = {
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
LOG_LEVEL: 'silent',
NODE_ENV: 'test',

View file

@ -3,7 +3,7 @@ import { loadConfig } from '../../src/config.js';
const base = {
DATABASE_URL: 'postgres://localhost/staff',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
};
@ -18,6 +18,33 @@ describe('runtime modes', () => {
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
/standalone is not allowed/,
));
it('requires a standalone tenant ID', () => {
expect(() => loadConfig({ ...base, STANDALONE_TENANT_ID: undefined })).toThrow(
/STANDALONE_TENANT_ID/,
);
});
it('allows the placeholder only in development on loopback', () => {
expect(
loadConfig({
...base,
NODE_ENV: 'development',
HOST: '127.0.0.1',
STANDALONE_TENANT_ID: '01J00000000000000000000000',
}).STANDALONE_TENANT_ID,
).toBe('01J00000000000000000000000');
});
it('rejects the placeholder in test mode and development on a non-loopback host', () => {
const placeholder = {
...base,
STANDALONE_TENANT_ID: '01J00000000000000000000000',
};
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'test' })).toThrow(
/allowed only in development on loopback/,
);
expect(() => loadConfig({ ...placeholder, NODE_ENV: 'development', HOST: '0.0.0.0' })).toThrow(
/allowed only in development on loopback/,
);
});
it('treats empty optional platform variables as absent in compose', () =>
expect(
loadConfig({