Skip to content

How-To: Asset-Ausleihe / -Rückgabe-Verwaltung

Demonstriert die exklusive Asset-Haltungsverfolgung mit einem Append-only-Prüfprotokoll. Field Trial: FT194 (../NENE2-FT/assetlog/).


Muster-Zusammenfassung

AspektAnsatz
Exklusives Haltenholder_id INTEGER — NULL = verfügbar, nicht-null = gehalten
Ausleih-Konflikt409 wenn holder_id IS NOT NULL vor dem Aktualisieren
Falsche-Halter-Rückgabe403 wenn holder_id != userId
PrüfprotokollAppend-only asset_history-Zeilen bei jeder Zustandsänderung
IDOR-VerhinderungÖffentliche API verbirgt holder_id; Admin-Schlüssel erforderlich zum Anzeigen
Admin-Schlüsselhash_equals()-Vergleich in konstanter Zeit, fail-closed bei leerem Schlüssel
BenutzeridentitätX-User-Id-Header; ctype_digit() + Längenschutz, kein Regex

Routen

MethodePfadAuthBeschreibung
POST/assetsX-Admin-KeyAsset erstellen
GET/assetsAlle Assets auflisten
GET/assets/{id}Einzelnes Asset abrufen
POST/assets/{id}/checkoutX-User-IdAsset ausleihen
POST/assets/{id}/checkinX-User-IdAsset zurückgeben
GET/assets/{id}/historyPrüfprotokoll

Datenbankschema

sql
CREATE TABLE assets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    holder_id  INTEGER,           -- NULL = verfügbar
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

CREATE TABLE asset_history (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    asset_id INTEGER NOT NULL,
    user_id  INTEGER NOT NULL,
    action   TEXT    NOT NULL,   -- 'checkout' | 'checkin'
    acted_at TEXT    NOT NULL,
    FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);

Exklusives Ausleih-Muster

php
public function checkout(int $assetId, int $userId): string
{
    $asset = $this->findById($assetId);
    if ($asset === null) return 'not_found';
    if (!$asset->isAvailable()) return 'unavailable';   // 409

    $now = $this->now();
    $this->pdo->prepare(
        'UPDATE assets SET holder_id = :uid, updated_at = :now WHERE id = :id AND holder_id IS NULL'
    )->execute([...]);

    $this->appendHistory($assetId, $userId, 'checkout', $now);
    return 'success';
}

Der WHERE holder_id IS NULL-Guard verhindert Doppelausleihen selbst bei gleichzeitigen Anfragen (SQLite serialisiert Schreibvorgänge; MySQL/PgSQL benötigen eine Transaktion oder SELECT FOR UPDATE).


IDOR-Verhinderung

php
// Öffentliche Antwort — kein holder_id
public function toPublicArray(): array
{
    return ['id' => $this->id, 'name' => $this->name, 'available' => $this->isAvailable(), ...];
}

// Admin-Antwort — enthält holder_id
public function toAdminArray(): array
{
    return [..., 'holder_id' => $this->holderId];
}

Der Handler prüft isAdmin() und wählt die richtige Projektion:

php
fn (Asset $a) => $isAdmin ? $a->toAdminArray() : $a->toPublicArray()

Admin-Schlüssel (fail-closed)

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') return false;   // kein Schlüssel konfiguriert → verweigern
    $provided = $request->getHeaderLine('X-Admin-Key');
    return $provided !== '' && hash_equals($this->adminKey, $provided);
}

Benutzer-ID-Validierung

php
private function resolveUserId(ServerRequestInterface $request): ?int
{
    $raw = $request->getHeaderLine('X-User-Id');
    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) return null;
    $id = (int) $raw;
    return $id > 0 ? $id : null;
}

ctype_digit() ist O(n) und ReDoS-sicher. Längenbegrenzung verhindert Integer-Überlauf.


Fehler-Mapping

Repository-ErgebnisHTTP-Status
success200 / 201
not_found404
unavailable409 Conflict
not_holder403 Forbidden
already_available409 Conflict

Test-Hinweise

  • AppFactory::create(?PDO, ?string) akzeptiert In-Memory-SQLite für Unit-Tests.
  • withParsedBody($body) muss für Test-Anfragen aufgerufen werden — Nyholm PSR-7 parst JSON nicht automatisch.
  • Öffentliche Listen-/Abruf-Assertions prüfen, dass der holder_id-Schlüssel fehlt (assertArrayNotHasKey).
  • Lebenszyklustest: Ausleihen → Konflikt → Rückgabe → Erneutes Ausleihen durch anderen Benutzer.

Veröffentlicht unter der MIT-Lizenz.