Skip to content

How-to: Delegierte Zugriffserteilungen

FT-Referenz: FT282 (NENE2-FT/grantlog) — Delegierte Zugriffserteilungen: bereichsbegrenzte (read/write/admin) zeitlich begrenzte Ressourcenzugriffe, UNIQUE(grantor, grantee, resource) + CHECK(grantor != grantee), IDOR → 404, Soft-Delete-Widerruf, Nutzungszähler, GrantScope.satisfies()-Hierarchie, 23 Tests / 71 Assertions PASS.

Ebenfalls validiert in FT176 — ursprüngliche Implementierung.

Benutzerindividuelle, zeitlich begrenzte, widerrufbare Zugriffsdelegation — ein Erteiler (Grantor) gewährt einem Empfänger (Grantee) bereichsbegrenzten Zugriff auf eine benannte Ressource für ein begrenztes Zeitfenster.


Überblick

Delegierte Zugriffserteilungen ermöglichen es einem Benutzer (grantor), einem anderen Benutzer (grantee) zeitlich begrenzten, bereichsbegrenzten Zugriff auf einen Ressourcenbezeichner zu gewähren. Beispiel: "Dokument:42 als Nur-Lese-Zugriff mit Benutzer 7 teilen, läuft in 24 Stunden ab, jederzeit widerrufbar."

Wesentliche Eigenschaften:

  • Mehrere Parteien — Erteiler und Empfänger sind immer verschiedene Benutzer; Selbstgewährungen werden abgelehnt.
  • Zustandsmaschine — aktiv → widerrufen (einseitig); der abgelaufene Zustand wird aus expires_at berechnet.
  • Opake Ressourceresource ist ein Freiformat-String; der Server speichert ihn unverändert.
  • Idempotente Eindeutigkeit — eine eindeutige Erteilung pro (grantor_id, grantee_id, resource).
  • IDOR-sicher — alle Eigentumsüberprüfungen geben 404 zurück, nicht 403, um Existenz-Enumeration zu verhindern.

Schema

sql
CREATE TABLE grants (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    grantor_id  INTEGER NOT NULL,
    grantee_id  INTEGER NOT NULL,
    resource    TEXT    NOT NULL,
    scope       TEXT    NOT NULL DEFAULT 'read',
    expires_at  TEXT    NOT NULL,
    revoked_at  TEXT,
    used_count  INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL,
    UNIQUE (grantor_id, grantee_id, resource),
    CHECK (scope IN ('read', 'write', 'admin')),
    CHECK (grantor_id != grantee_id)
);

CHECK (grantor_id != grantee_id) ist eine Tiefenschutzmaßnahme — Selbstgewährung muss auch auf Anwendungsebene abgelehnt werden, um eine klare Fehlermeldung zu liefern.


Domain-Schicht

GrantScope-Enum mit Hierarchie

php
enum GrantScope: string
{
    case Read  = 'read';
    case Write = 'write';
    case Admin = 'admin';

    public function satisfies(self $required): bool
    {
        $rank = [self::Read->value => 0, self::Write->value => 1, self::Admin->value => 2];
        return $rank[$this->value] >= $rank[$required->value];
    }
}

Grant-Entity — berechnete Zustandsmethoden

php
final readonly class Grant
{
    public function isExpired(string $now): bool  { return $this->expiresAt <= $now; }
    public function isRevoked(): bool             { return $this->revokedAt !== null; }
    public function isActive(string $now): bool   { return !$this->isExpired($now) && !$this->isRevoked(); }
}

Zuerst auf Widerruf prüfen, dann auf Ablauf — beide Pfade geben 403 zurück, aber mit unterschiedlichen Fehlerkörpern, sodass Empfänger verstehen, warum der Zugriff verweigert wurde, ohne Systeminterna preiszugeben.


HTTP-Endpunkte

MethodePfadAuthZweck
POST/grantsX-User-Id (Erteiler)Erteilung erstellen
GET/grants/issuedX-User-IdVom Aufrufer ausgestellte Erteilungen auflisten
GET/grants/receivedX-User-IdVom Aufrufer empfangene Erteilungen auflisten
DELETE/grants/{id}X-User-Id (muss Erteiler sein)Erteilung widerrufen
POST/grants/{id}/useX-User-Id (muss Empfänger sein)Erteilung nutzen

Validierungsregeln

FeldRegel
grantee_idMuss ein JSON-Integer > 0 sein; String "2", null, boolean, float werden abgelehnt
resourceNicht-leerer String; ≤ 500 UTF-8-Zeichen; wird unverändert gespeichert (opak)
scopeMuss eines von read / write / admin sein
expires_atGültiges ISO 8601; muss in der Zukunft liegen; ≤ 30 Tage ab jetzt
Selbstgewährunggrantee_id == grantor X-User-Id → 422

Striktes Integer-Feld-Parsing

Eine häufige Schwachstelle ist implizite Typumwandlung — "2" (JSON-String) als 2 (int) akzeptieren. Explizite Typüberprüfung verwenden:

php
private function intField(array $body, string $key): ?int
{
    if (!array_key_exists($key, $body)) {
        return null;
    }
    // is_int() gibt false für "2", null, true, 2.5 zurück — nur true für PHP int
    return is_int($body[$key]) ? $body[$key] : null;
}

Hinweis: 2.0 (PHP float) ist nach json_encode nicht von 2 (int) zu unterscheiden — 2.5 verwenden, um Float-Ablehnung in Unit-Tests zu testen.


Zustandsmaschine

         revoke()
aktiv ─────────────→ widerrufen   (409 bei zweitem Widerruf)

  │ expires_at ≤ jetzt

abgelaufen

widerrufen + abgelaufen → widerrufen gewinnt (zuerst auf Widerruf prüfen)

Doppelter Widerruf muss mit 409 abgelehnt werden, nicht stillschweigend akzeptiert. Der revoked_at-Zeitstempel darf sich beim zweiten Aufruf nicht ändern.


IDOR-Schutzmuster

php
// DELETE /grants/{id}
$grant = $this->repository->find($id);

// 404 für sowohl "nicht gefunden" ALS AUCH "nicht Ihre Erteilung" zurückgeben
// Hier niemals 403 zurückgeben — das würde Existenz preisgeben
if ($grant === null || $grant->grantorId !== $callerId) {
    return $this->responseFactory->create(['error' => "Grant #{$id} not found."], 404);
}

Dasselbe Muster gilt für POST /grants/{id}/use — 404 zurückgeben, wenn der Aufrufer nicht der Empfänger ist.


Mehrparteien-Verwechslungsschutz

SzenarioErwartetes Ergebnis
Erteiler ruft POST /grants/{id}/use (eigene Erteilung) auf404 — Erteiler ist nicht der Empfänger
Empfänger ruft DELETE /grants/{id} auf404 — Empfänger ist nicht der Erteiler
Benutzer 3 ruft eines der oben genannten für eine Erteilung zwischen Benutzer 1 und 2 auf404 — IDOR
X-User-Id: 0 oder X-User-Id: -1401 — Nicht-positive IDs werden abgelehnt
Fehlende X-User-Id401

Sicherheits-Checkliste (ATK-01 bis ATK-12)

#AngriffsvektorGegemaßnahme
ATK-01Abgelaufene Erteilung (Uhrzeitgrenze)isExpired()-Vergleich; DB expires_at rückdatiert im Test
ATK-02Widerruf-Zustand-UmgehungisRevoked()-Prüfung vor der Verwendung
ATK-03Selbstgewährung (grantor == grantee)App-Layer 422 + DB CHECK
ATK-04Falscher Empfänger verwendet Erteilung (IDOR)404, nicht 403
ATK-05Nicht-Erteiler widerruft Erteilung (IDOR)404, nicht 403; ursprüngliche Erteilung bleibt aktiv
ATK-06Vergangenes expires_at bei Erstellungstrtotime($expiresAt) <= strtotime($now) → 422
ATK-07Typverwirrung bei grantee_idis_int()-Strict-Check; lehnt "2", null, true, 2.5 ab
ATK-08Pfadüberquerung in resourceOpake Speicherung; kein Dateisystemzugriff
ATK-09SQL-Injection in resource/scopeParametrisierte Abfragen; Scope-Enum lehnt injizierte Werte ab
ATK-10Unicode/BIDI in resourceWird unverändert gespeichert; Homoglyphen und BIDI sind unterschiedliche Ressourcen
ATK-11Doppelter Widerruf (Zustandsmaschine)409 beim zweiten Widerruf; revoked_at unveränderlich nach erstem
ATK-12Erteiler verwendet eigene Erteilung als Empfänger404 — Parteienrollen strikt durchgesetzt

Testansatz

  • ATK-01, ATK-02: DB-Zustand direkt erzwingen (UPDATE grants SET expires_at/revoked_at), um Zeitreisen ohne Schlaf zu simulieren.
  • ATK-07: "2" (String), null, true, 2.5 (Float) testen — nicht 2.0 (nach PHP json_encode nicht von int zu unterscheiden).
  • ATK-10: "\u{202E}" (BIDI-Override) und kyrillische Homoglyphen verwenden, um unveränderte Speicherung zu bestätigen.
  • ATK-11: Sicherstellen, dass revoked_at-Wert in der DB nach zweitem Widerrufsversuch unverändert ist.

Was Sie NICHT tun sollten

Anti-MusterRisiko
Kein UNIQUE (grantor_id, grantee_id, resource)Dasselbe Paar kann doppelte Erteilungen erstellen; Empfänger hat veraltete und aktive Erteilungen für dieselbe Ressource
Hard-Delete bei WiderrufVerliert Audit-Historie; kann nicht sagen, wann Zugriff entfernt wurde oder wie oft er genutzt wurde
403 statt 404 für Eigentumsüberprüfung zurückgebenGibt nicht-autorisierten Aufrufern Existenz der Erteilung preis; IDOR-Enumerationsfläche
Kein CHECK (grantor_id != grantee_id)Tiefenschutz fehlt; Selbstgewährungen könnten durchschlüpfen, wenn App-Layer-Prüfung umgangen wird
Freiformat-Scope-String akzeptierenTippfehler verfallen stillschweigend auf read; GrantScope::tryFrom() verwenden, um unbekannte Werte abzulehnen
Scope-Prüfung ohne satisfies()-Hierarchiewrite-Benutzer muss read-Prüfungen separat bestehen; Hierarchie verwenden, um alle niedrigeren Ebenen zu prüfen
Kein maximales TTL für expires_atErteiler erstellt 100-Jahres-Erteilungen; effektiv dauerhafter Zugriff ohne Überprüfung
Kein Ressourcenlängenlimit10-MB-Ressource-String verursacht langsame Index-Lookups und Speicherzuteilung
Ablauf vor Widerruf prüfenWiderrufene + abgelaufene Erteilung sollte "widerrufen" anzeigen — Widerruf gewinnt in der Zustandsmaschine
used_count clientseitig verfolgenClient meldet Nutzungszähler; Server muss den Zähler besitzen

Veröffentlicht unter der MIT-Lizenz.