Skip to content

How-to: Resource Booking System

Overview

This guide covers building a resource booking API with NENE2. Features include capacity enforcement, double-booking prevention, per-user IDOR isolation, and admin cancellation.

Reference implementation: ../NENE2-FT/bookinglog/


Schema Design

sql
CREATE TABLE IF NOT EXISTS resources (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL UNIQUE,
    capacity   INTEGER NOT NULL DEFAULT 1,
    created_at TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS bookings (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    resource_id INTEGER NOT NULL,
    user_id     INTEGER NOT NULL,
    slot_date   TEXT    NOT NULL,   -- 'YYYY-MM-DD'
    slot_hour   INTEGER NOT NULL,   -- 0-23
    created_at  TEXT    NOT NULL,
    cancelled   INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
    UNIQUE (resource_id, user_id, slot_date, slot_hour)
);

Key constraints:

  • UNIQUE (resource_id, user_id, slot_date, slot_hour) — one booking per user per slot.
  • cancelled soft-delete flag — preserve history while allowing rebooking.
  • Capacity checked at query time (count active bookings vs resource.capacity).

Route Table

MethodPathAuthDescription
GET/resourcesNoneList all resources
POST/resourcesAdminCreate a resource
POST/bookingsUserBook a slot
GET/bookingsUserList own bookings
GET/bookings/{id}UserGet one booking
DELETE/bookings/{id}User/AdminCancel a booking

Double-Booking Prevention

First, check if the user already has this slot (application-level):

php
$stmt = $this->pdo->prepare(
    'SELECT id FROM bookings WHERE resource_id = :rid AND user_id = :uid
     AND slot_date = :d AND slot_hour = :h AND cancelled = 0'
);
$stmt->execute([...]);
if ($stmt->fetch() !== false) {
    return 'double_booking';
}

Then check capacity:

php
$count = $this->countSlotBookings($resourceId, $date, $hour);
if ($count >= (int) $resource['capacity']) {
    return 'capacity_full';
}

IDOR Isolation

Users can only read/cancel their own bookings. Return 404 (not 403) to avoid revealing existence:

php
if (!$this->isAdmin($req) && (int) $booking['user_id'] !== $uid) {
    return $this->problem(404, 'not-found', 'Booking not found.');
}

Admin Cancel Without X-User-Id

Admin can cancel any booking without providing their own user ID:

php
$isAdmin = $this->isAdmin($req);
$uid     = $this->uid($req);
if ($uid === null && !$isAdmin) {
    return $this->problem(400, 'bad-request', 'X-User-Id required.');
}
$result = $this->repo->cancel($id, $uid ?? 0, $isAdmin);

Validation Rules

FieldRule
resource_idis_int() + positive
slot_dateregex /\A\d{4}-\d{2}-\d{2}\z/
slot_houris_int() + 0–23
capacityis_int() + positive
namenon-empty string

HTTP Status Codes

SituationStatus
Resource created201
Booking confirmed201
Booking found / list200
No X-User-Id400
Invalid field type422
Invalid date format422
slot_hour out of 0–23422
Resource not found404
Booking not found404
No admin key403
Cancel own booking200
Cancel other's booking403
Double booking409
Capacity full409

VULN Patterns Covered

VULNPatternDefence
AIDOR: user sees other's bookingWHERE user_id = :uid + 404
BNegative resource_idis_int() + > 0 check
CZero slot_hour (midnight)0-23 range allows 0
DSQL injection in slot_dateRegex validation + parameterized query
EString resource_id type jugglingis_int() strict check
FDouble bookingExistence check before INSERT
GCapacity overflowCOUNT vs capacity check
HNo X-User-Id400 with message
ICancel other user's bookinguser_id ownership check → 403
JList leaks other user's dataWHERE user_id = :uid
KAdmin cancels any bookingisAdmin bypass ownership
Lslot_hour = 24 (out of range)$hour > 23 → 422

Released under the MIT License.