fix: require explicit pilot tenant and reject development placeholder
This commit is contained in:
parent
f5ac179623
commit
535052ea14
10 changed files with 80 additions and 16 deletions
|
|
@ -11,5 +11,6 @@ PLATFORM_GATEWAY_URL=http://host.docker.internal:3000
|
||||||
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
PLATFORM_JWT_ISSUER=https://platform.example.invalid
|
||||||
PLATFORM_JWT_AUDIENCE=krona-staff
|
PLATFORM_JWT_AUDIENCE=krona-staff
|
||||||
PLATFORM_JWKS_URL=https://platform.example.invalid/.well-known/jwks.json
|
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
|
STANDALONE_ACTOR_ID=01J00000000000000000000001
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
# Перед следующей командой заполните STANDALONE_TENANT_ID в .env:
|
||||||
|
# используйте ULID организации из Krona Business Platform, не ID сотрудника.
|
||||||
docker compose --profile tools run --rm migrate
|
docker compose --profile tools run --rm migrate
|
||||||
docker compose up --build app postgres
|
docker compose up --build app postgres
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ services:
|
||||||
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
PLATFORM_JWT_ISSUER: ${PLATFORM_JWT_ISSUER:-}
|
||||||
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
PLATFORM_JWT_AUDIENCE: ${PLATFORM_JWT_AUDIENCE:-}
|
||||||
PLATFORM_JWKS_URL: ${PLATFORM_JWKS_URL:-}
|
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}
|
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID:-01J00000000000000000000001}
|
||||||
ports: ['8080:8080']
|
ports: ['8080:8080']
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,20 @@ cd "$(dirname "$0")"
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p secrets
|
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
|
if [ ! -f .env ]; then
|
||||||
|
validate_tenant
|
||||||
migration_password="$(openssl rand -hex 32)"
|
migration_password="$(openssl rand -hex 32)"
|
||||||
app_password="$(openssl rand -hex 32)"
|
app_password="$(openssl rand -hex 32)"
|
||||||
manager_token="$(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_MANAGER_TOKEN=%s\n' "$manager_token"
|
||||||
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
|
printf 'STANDALONE_TERMINAL_TOKEN=%s\n' "$terminal_token"
|
||||||
printf 'HTTPS_PFX_PASSPHRASE=%s\n' "$pfx_passphrase"
|
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_ACTOR_ID=01J00000000000000000000001\n'
|
||||||
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
|
printf 'STANDALONE_TERMINAL_ACTOR_ID=01J00000000000000000000002\n'
|
||||||
} > .env
|
} > .env
|
||||||
|
|
@ -25,6 +38,9 @@ if [ ! -f .env ]; then
|
||||||
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
printf '%s\n' "$terminal_token" > secrets/terminal.token
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
. ./.env
|
||||||
|
validate_tenant
|
||||||
|
|
||||||
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$(cd ../.. && pwd):/app:ro" \
|
-v "$(cd ../.. && pwd):/app:ro" \
|
||||||
|
|
@ -34,6 +50,7 @@ if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
. ./.env
|
. ./.env
|
||||||
|
validate_tenant
|
||||||
if [ ! -f secrets/staff.pfx ]; then
|
if [ ! -f secrets/staff.pfx ]; then
|
||||||
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
|
||||||
-keyout secrets/staff.key -out secrets/staff.crt \
|
-keyout secrets/staff.key -out secrets/staff.crt \
|
||||||
|
|
|
||||||
|
|
@ -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 не включаются до появления реального сценария очередей или файлов.
|
Локально скопируйте `.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.
|
Для обновления старого Docker volume, созданного версией с ролью `krona_staff`, задайте в `.env` `POSTGRES_MIGRATION_USER=krona_staff` и прежний `POSTGRES_MIGRATION_PASSWORD`, затем выполните обычный `docker compose up --build`. Сохраняйте эту роль владельцем миграций для данного volume: автоматического переименования владельца существующей базы нет, а перенос владения требует отдельного DBA-плана с повторной настройкой default privileges.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const PLACEHOLDER_TENANT_ID = '01J00000000000000000000000';
|
||||||
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
|
|
@ -10,7 +13,10 @@ const configSchema = z.object({
|
||||||
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||||
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
|
||||||
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
|
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
|
||||||
z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(),
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/)
|
||||||
|
.optional(),
|
||||||
),
|
),
|
||||||
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
|
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
|
||||||
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
|
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),
|
||||||
|
|
@ -20,7 +26,9 @@ const configSchema = z.object({
|
||||||
PLATFORM_JWT_ISSUER: optionalUrl(),
|
PLATFORM_JWT_ISSUER: optionalUrl(),
|
||||||
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
|
PLATFORM_JWT_AUDIENCE: emptyStringAsUndefined(z.string().optional()),
|
||||||
PLATFORM_JWKS_URL: optionalUrl(),
|
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}$/),
|
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||||
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||||
|
|
@ -39,6 +47,14 @@ export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const config = configSchema.parse(env);
|
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)) {
|
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');
|
throw new Error('HTTPS_PFX_PATH and HTTPS_PFX_PASSPHRASE must be configured together');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function assertOpenApiDocument(value: unknown): asserts value is OpenApiDocument
|
||||||
|
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
LOG_LEVEL: 'silent',
|
LOG_LEVEL: 'silent',
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { loadConfig } from '../../src/config.js';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
DATABASE_URL: 'postgres://localhost/staff',
|
DATABASE_URL: 'postgres://localhost/staff',
|
||||||
STANDALONE_TENANT_ID: '01J00000000000000000000000',
|
STANDALONE_TENANT_ID: '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||||
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
STANDALONE_ACTOR_ID: '01J00000000000000000000001',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,6 +18,33 @@ describe('runtime modes', () => {
|
||||||
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
|
expect(() => loadConfig({ ...base, NODE_ENV: 'production' })).toThrow(
|
||||||
/standalone is not allowed/,
|
/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', () =>
|
it('treats empty optional platform variables as absent in compose', () =>
|
||||||
expect(
|
expect(
|
||||||
loadConfig({
|
loadConfig({
|
||||||
|
|
@ -29,12 +56,12 @@ describe('runtime modes', () => {
|
||||||
}).PLATFORM_GATEWAY_URL,
|
}).PLATFORM_GATEWAY_URL,
|
||||||
).toBeUndefined());
|
).toBeUndefined());
|
||||||
it('requires VAPID keys to be configured together if provided', () => {
|
it('requires VAPID keys to be configured together if provided', () => {
|
||||||
expect(() =>
|
expect(() => loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' })).toThrow(
|
||||||
loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }),
|
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
);
|
||||||
expect(() =>
|
expect(() => loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' })).toThrow(
|
||||||
loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }),
|
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
|
||||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
);
|
||||||
const config = loadConfig({
|
const config = loadConfig({
|
||||||
...base,
|
...base,
|
||||||
VAPID_PUBLIC_KEY: 'pubkey',
|
VAPID_PUBLIC_KEY: 'pubkey',
|
||||||
|
|
@ -46,4 +73,3 @@ describe('runtime modes', () => {
|
||||||
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue