Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dist/
.env
.env.local
.env.production
.env.harden
.env.*.local

# IDE
Expand Down
1,207 changes: 7 additions & 1,200 deletions README.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions ops/harden/.env.harden.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# HAIP operator harden — live probe env
#
# cp ops/harden/.env.harden.example .env.harden
# # edit values, then:
# set -a && source .env.harden && set +a
# pnpm harden:live
#
# Do NOT commit .env.harden — it contains bearer tokens.

# API base ending in /api (compose default shown)
HAIP_API_BASE=http://localhost:3000/api

# Keycloak bearer JWTs (property_ids claim must match the property below)
TOKEN_A=
TOKEN_B=

# Property UUIDs
PROPERTY_A=
PROPERTY_B=

# Optional: reservation that belongs only to property B
# RESERVATION_IN_B=

# Optional: JWT missing property_ids or from wrong issuer (expect 401)
# TOKEN_BAD=

# Optional: override request timeout ms (default 15000)
# HARDEN_TIMEOUT_MS=15000
56 changes: 56 additions & 0 deletions ops/harden/CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Production go-live checklist

Use this before exposing HAIP to real guests or chargeable traffic.
Details: [`docs/deployment.md`](../../docs/deployment.md) and
[`.env.production.example`](../../.env.production.example).

## Compose & boot

- [ ] Copied `.env.production.example` → `.env.production` and filled secrets
- [ ] Started with prod overlay + auth profile:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build
```
- [ ] `AUTH_ENABLED=true` (required in production)
- [ ] `HAIP_ALLOW_INSECURE` is **unset / empty** (never `true` in production)
- [ ] `STRIPE_MODE` is `test` until ready for real charges; then `live` with live keys
- [ ] API boots cleanly; `GET /api/v1/health` returns `status: ok`

## Auth (Keycloak)

- [ ] Keycloak runs in production mode with TLS (not `start-dev` exposed publicly)
- [ ] Strong Keycloak admin credentials (changed from defaults)
- [ ] Realm `haip` (or your realm) issues JWTs with `property_ids` and `roles`
- [ ] Unauthenticated `GET /api/v1/reservations?propertyId=<uuid>` → **401**
- [ ] Dashboard built / served with auth on (`VITE_AUTH_ENABLED=true` in prod overlay)

## Multi-tenancy

- [ ] At least two test properties exist
- [ ] User A JWT has only property A; user B only property B
- [ ] `pnpm harden:live` passes tenant-isolation probes (see [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md))
- [ ] SPA routes always include `propertyId` on detail pages

## Payments & Connect

- [ ] Stripe secret + webhook secret match `STRIPE_MODE`
- [ ] Stripe webhook endpoint receives events (signature verified)
- [ ] `CONNECT_API_KEY` set when auth is on (OTAIP / Connect agents)
- [ ] Booking engine keys generated under Settings → Booking Engine (when auth on)

## Network & ops

- [ ] TLS terminated at reverse proxy (Caddy/nginx/etc.)
- [ ] `CORS_ORIGINS` set if browser origin ≠ API host
- [ ] Night audit / group cutoff cron configured ([`docs/operations/cron.md`](../../docs/operations/cron.md))
- [ ] Database backup + restore tested once
- [ ] Rate limiting considered for public origins

## Product smoke (manual)

- [ ] [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) completed on property A — zero Critical on check-in, rooms, folios, night audit, housekeeping
- [ ] At least three desk [`vignettes/`](./vignettes/) exercised (recommend `base-01`, `base-07`, `base-15`)

## Gate

Do **not** put real hotel traffic on the instance while Critical surface failures or failed tenant-isolation probes remain open.
10 changes: 10 additions & 0 deletions ops/harden/PRODUCTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Harden pack production pointer

Before going live, run:

```bash
pnpm harden:local
pnpm harden:live
```

See [`ops/harden/`](./) and the root README Production checklist.
79 changes: 79 additions & 0 deletions ops/harden/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# HAIP operator harden pack

Playbooks and a small CLI so operators can harden a **self-hosted HAIP**
deployment (Keycloak + docker compose) before go-live and against a live URL.

## What this is

| Piece | Purpose |
|-------|---------|
| [`CHECKLIST.md`](./CHECKLIST.md) | Production go-live checklist |
| [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md) | Cross-tenant deny criteria (Keycloak JWTs) |
| [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) | Full SPA surface walk before chargeable use |
| [`vignettes/`](./vignettes/) | Desk ops stories (guest → staff → delight/block) |
| [`cli/`](./cli/) | `harden:local` and `harden:live` HTTP probes |

## Quick start

### Pre-go-live (local / compose)

From the HAIP repo root:

```bash
cp .env.production.example .env.production
# Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc.

docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build

pnpm harden:local
```

Then walk [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) and a few [`vignettes/`](./vignettes/) against the dashboard.

### Live instance

```bash
cp ops/harden/.env.harden.example .env.harden
# Fill HAIP_API_BASE, TOKEN_A, TOKEN_B, PROPERTY_A, PROPERTY_B

set -a && source .env.harden && set +a
pnpm harden:live
```

Exit code `0` = all probes passed; non-zero = at least one fail. See the printed table.

## Keycloak tokens

Tokens must be JWTs issued by your Keycloak realm (`haip` by default) for the
API client (`haip-api`). Claims the API expects:

| Claim | Meaning |
|-------|---------|
| `property_ids` | Array of property UUIDs this user may access |
| `roles` | HAIP roles (e.g. `admin`, `front_desk`, `readonly`) |

**User A** should have `property_ids=[PROPERTY_A]` only.
**User B** should have `property_ids=[PROPERTY_B]` only.

How you mint tokens is up to your deployment — the CLI only needs the bearer strings.

`HAIP_API_BASE` is the API origin **including** `/api` (compose default: `http://localhost:3000/api`).
Probes call `{HAIP_API_BASE}/v1/...`.

## Modes

| Command | Needs running stack? | Needs tokens? |
|---------|----------------------|---------------|
| `pnpm harden:local` | Preferred (for HTTP checks); file checks always run | Optional |
| `pnpm harden:live` | Yes (your URL) | **Required** |

## Manual ops (after CLI green)

1. Complete [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) on property A.
2. Run several vignettes from [`vignettes/`](./vignettes/) (start with `base-01`, `base-07`, `base-15`).
3. Re-run `pnpm harden:live` after any auth or multi-tenancy change.

## Non-goals

- Automated full-SPA Playwright campaigns (use surface smoke + vignettes manually in v1)
- Exhaustive penetration testing — this pack is an operator readiness gate, not a red-team suite
61 changes: 61 additions & 0 deletions ops/harden/SURFACE_SMOKE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Full-surface smoke (self-hosted HAIP)

Run this on every candidate production (or staging twin) deploy before putting
real guests or chargeable traffic on the instance. Desk vignettes alone are
**not** enough.

## Environment

- Dashboard served by your HAIP API (compose: `http://localhost:3000`)
- Sign in via Keycloak with a staff/admin user for **property A**
- Always open routes with `?propertyId=<PROPERTY_A>`
- Never put passwords in notes or screenshots you share

## Rule

Click the real control. If nothing happens → **BROKEN**.
Pretty empty page with no path to do the job → **EMPTY_SHELL**.
Nav that lies / no feedback after a critical action → **CONFUSING**.

Classifications: `BROKEN` | `EMPTY_SHELL` | `CONFUSING` | `PARTIAL` | `WORKS`

## Checklist (engine SPA — no skipping)

| # | Surface | Route | Must prove |
|---|---------|-------|------------|
| 1 | Dashboard | `/` | Loads for the property; not a dead shell |
| 2 | Check-in / Front desk | `/front-desk` | Confirm check-in completes; walk-in path works; clear errors |
| 3 | Reservations | `/reservations` | List + open a reservation; create/modify path clear |
| 4 | Guests | `/guests` | Profile useful mid-shift (stay context / notes — not name-only) |
| 5 | Rooms status | `/rooms` | Change status sticks + visible feedback |
| 6 | Room types | `/rooms/types` | Types list/edit operable |
| 7 | Housekeeping | `/housekeeping` | Tasks load (or clear actionable empty + generate works) |
| 8 | Folios | `/folios` | See bill; post/settle path clear |
| 9 | Cashier | `/cashier` | Post/settle flow usable when folio has balance |
| 10 | Night audit | `/night-audit` | After run: success state + history; not “run again?” forever |
| 11 | Rate plans | `/rate-plans` | Calculate/edits not decorative |
| 12 | Reports | `/reports` | Primary reports load; errors visible |
| 13 | Groups | `/groups` | Group block list/detail operable |
| 14 | Channels | `/channels` | Connection path or honest “not configured” |
| 15 | Communications | `/communications` | Connected path or honest “not configured” |
| 16 | Reviews | `/reviews` | Sync/manual path clear — not a dead empty |
| 17 | Settings / Users | `/settings` | Property settings + users/roles operable |
| 18 | Booking admin | Settings → Booking Engine | Booking key generate/rotate when auth on |
| 19 | Import | `/import` | Import entry honest (works or clear empty) |

Also smoke Walk-In party link + Guest Details — but **never instead of** this list.
Pair with [`vignettes/`](./vignettes/) for desk realism.

## Outputs (suggested)

Write under a local folder you keep private (e.g. `harden-runs/YYYY-MM-DD-surface-smoke/`):

1. `AUDIT.json` — `{id, surface, route, classification, notes}` × each row
2. `SUMMARY.md` — counts of WORKS / BROKEN / EMPTY_SHELL / …
3. Screenshots only if useful; scrub guest PII

## Gate

**Invalid run:** skipped surfaces, or vignette-only run labeled as full product smoke.
**Valid run:** every row attempted.
**Go-live:** zero Critical on check-in, room status, folios, night-audit feedback, housekeeping.
49 changes: 49 additions & 0 deletions ops/harden/TENANT_ISOLATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Tenant isolation gate (self-hosted HAIP)

Run before enabling real hotel tenants. Uses two Keycloak users / properties.

## Prerequisites

- HAIP API with `AUTH_ENABLED=true`
- Two Keycloak users whose JWTs include:
- User A: `property_ids=[PROPERTY_A]`, roles include staff or `admin`
- User B: `property_ids=[PROPERTY_B]`
- Environment (see [`.env.harden.example`](./.env.harden.example)):

| Variable | Meaning |
|----------|---------|
| `HAIP_API_BASE` | API base ending in `/api` (e.g. `http://localhost:3000/api`) |
| `TOKEN_A` / `TOKEN_B` | Bearer JWTs for users A and B |
| `PROPERTY_A` / `PROPERTY_B` | Property UUIDs |
| `RESERVATION_IN_B` | Optional — reservation id that belongs only to B |

## Automated probe

```bash
set -a && source .env.harden && set +a
pnpm harden:live
```

## Pass criteria

1. `GET /v1/health` → **200** with `status: ok` (public)
2. `GET /v1/reservations?propertyId=PROPERTY_A` with **no** token → **401**
3. `GET /v1/reservations?propertyId=PROPERTY_B` with `TOKEN_A` → **403** (or **401** if claim missing)
4. `GET /v1/reservations?propertyId=PROPERTY_A` with `TOKEN_A` → **200** (A may read A)
5. If `RESERVATION_IN_B` is set: `GET /v1/reservations/RESERVATION_IN_B?propertyId=PROPERTY_B` with `TOKEN_A` → **403** or **404**
6. Token without usable `property_ids` / wrong issuer → **401** (configure a bad token as `TOKEN_BAD` optional)

## Multi-property owner invariants (same account)

An owner JWT may hold `property_ids=[A, B]`. Still required:

1. Request with `propertyId=A` + entity id that only exists under B → **404**
2. `POST /reservations` with `propertyId=A` and a `guestId` only linked at B → **404**
3. SPA property switch clears cached detail data; detail routes key by `propertyId`

These owner invariants are checklist items in v1 (exercise manually or with your own scripts).
The CLI covers the two-user cross-tenant deny path above.

## After changes

Re-run `pnpm harden:live` after any change to auth guards, property scoping, or JWT claim mapping.
102 changes: 102 additions & 0 deletions ops/harden/cli/harden.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* HAIP operator harden CLI
*
* node ops/harden/cli/harden.mjs local
* node ops/harden/cli/harden.mjs live
* node ops/harden/cli/harden.mjs all
*
* Env: see ops/harden/.env.harden.example
*/

import { printReport, env } from './lib.mjs';
import { runLocalFileProbes } from './probes/local.mjs';
import { runHealthProbes } from './probes/health.mjs';
import { runAuthOnProbes } from './probes/auth-on.mjs';
import { runTenantIsolationProbes } from './probes/tenant-isolation.mjs';

function usage() {
console.log(`Usage: haip-harden <local|live|all>

local File/compose checklist (+ optional HTTP if HAIP_API_BASE is set)
live Health + auth-on + tenant-isolation against HAIP_API_BASE
all local then live

Env: copy ops/harden/.env.harden.example → .env.harden and source it.
`);
}

async function runLive() {
/** @type {import('./lib.mjs').ProbeResult[]} */
const results = [];
results.push(...(await runHealthProbes()));
results.push(...(await runAuthOnProbes()));
results.push(...(await runTenantIsolationProbes()));
return results;
}

async function runLocal() {
/** @type {import('./lib.mjs').ProbeResult[]} */
const results = [...(await runLocalFileProbes())];

// If API base is configured, also hit health (and auth/tenant when tokens present)
if (env('HAIP_API_BASE')) {
console.log('HAIP_API_BASE set — running HTTP probes against local/target API…');
results.push(...(await runHealthProbes()));
if (env('PROPERTY_A')) {
results.push(...(await runAuthOnProbes()));
}
if (env('TOKEN_A') && env('TOKEN_B') && env('PROPERTY_A') && env('PROPERTY_B')) {
results.push(...(await runTenantIsolationProbes()));
} else {
results.push({
id: 'tenant-live-optional',
ok: true,
skip: true,
detail: 'TOKEN_A/B + PROPERTY_A/B not all set — skipped live tenant probes',
});
}
} else {
results.push({
id: 'http-optional',
ok: true,
skip: true,
detail: 'HAIP_API_BASE not set — file checks only; set it to probe a running API',
});
}

return results;
}

async function main() {
const mode = (process.argv[2] ?? '').toLowerCase();
if (!['local', 'live', 'all'].includes(mode)) {
usage();
process.exit(2);
}

console.log(`HAIP harden — mode=${mode}`);

/** @type {import('./lib.mjs').ProbeResult[]} */
let results = [];
if (mode === 'local' || mode === 'all') {
console.log('\n== local ==');
results = results.concat(await runLocal());
}
if (mode === 'live' || mode === 'all') {
console.log('\n== live ==');
if (!env('HAIP_API_BASE')) {
console.error('HAIP_API_BASE is required for live mode');
process.exit(2);
}
results = results.concat(await runLive());
}

const passed = printReport(results);
process.exit(passed ? 0 : 1);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading