Skip to content
Merged
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
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 the operator harden pack:

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

See [`README.md`](./README.md) and the root README **Production checklist** section.
85 changes: 85 additions & 0 deletions ops/harden/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 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
# 1. Configure production env
cp .env.production.example .env.production
# Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc.

# 2. Bring up the prod overlay (auth on)
docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build

# 3. Static + HTTP local checks
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 (password grant, client credentials + user impersonation,
or your IdP’s token endpoint) is up to your deployment — the CLI only needs the
bearer strings.

`HAIP_API_BASE` is the API origin **including** `/api` if your reverse proxy
serves the API under `/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 — if tokens are set, also runs live probes against local base |
| `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.
Loading
Loading