Skip to content

How-to: Delegated Access Grants

FT reference: FT282 (NENE2-FT/grantlog) — Delegated access grants: scoped (read/write/admin) time-limited resource access, UNIQUE(grantor, grantee, resource) + CHECK(grantor != grantee), IDOR → 404, soft-delete revocation, use-count tracking, GrantScope.satisfies() hierarchy, 23 tests / 71 assertions PASS.

Also validated in FT176 — original implementation.

Per-user, time-limited, revocable access delegation — a grantor gives a grantee scoped access to a named resource for a bounded time window.


Overview

Delegated Access Grants let one user (grantor) give another user (grantee) time-limited, scoped access to a resource identifier. Think "share document:42 as read-only with user 7, expires in 24 hours, revocable at any time."

Key properties:

  • Multi-party — grantor and grantee are always different users; self-grants are rejected.
  • State machine — active → revoked (one-way); expired state is computed from expires_at.
  • Opaque resourceresource is a free-form string; the server stores it verbatim.
  • Idempotent uniqueness — one unique grant per (grantor_id, grantee_id, resource).
  • IDOR-safe — all ownership checks return 404, not 403, to prevent existence enumeration.

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)
);

The CHECK (grantor_id != grantee_id) is a defence-in-depth measure — self-grant must also be rejected at the application layer for a clear error response.


Domain layer

GrantScope enum with hierarchy

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 — computed state methods

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(); }
}

Check revoked first, then expiry — both paths return 403 but with distinct error bodies so grantees understand why access failed without exposing system internals.


HTTP endpoints

MethodPathAuthPurpose
POST/grantsX-User-Id (grantor)Create a grant
GET/grants/issuedX-User-IdList grants issued by caller
GET/grants/receivedX-User-IdList grants received by caller
DELETE/grants/{id}X-User-Id (must be grantor)Revoke a grant
POST/grants/{id}/useX-User-Id (must be grantee)Use a grant

Validation rules

FieldRule
grantee_idMust be a JSON integer > 0; string "2", null, boolean, float rejected
resourceNon-empty string; ≤ 500 UTF-8 chars; stored verbatim (opaque)
scopeMust be one of read / write / admin
expires_atValid ISO 8601; must be in the future; ≤ 30 days from now
Self-grantgrantee_id == grantor X-User-Id → 422

Strict integer field parsing

A common vulnerability is implicit type coercion — accepting "2" (JSON string) as 2 (int). Use explicit type checking:

php
private function intField(array $body, string $key): ?int
{
    if (!array_key_exists($key, $body)) {
        return null;
    }
    // is_int() returns false for "2", null, true, 2.5 — only true for PHP int
    return is_int($body[$key]) ? $body[$key] : null;
}

Note: 2.0 (PHP float) is indistinguishable from 2 (int) after json_encode — use 2.5 to test float rejection in unit tests.


State machine

         revoke()
active ─────────────→ revoked   (409 on second revoke)

  │ expires_at ≤ now

expired

revoked + expired → revoked wins (check revoked first)

Double-revoke must be rejected with 409, not silently accepted. The revoked_at timestamp must not change on the second call.


IDOR protection pattern

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

// Return 404 for both "not found" AND "not your grant"
// Never return 403 here — that would leak existence
if ($grant === null || $grant->grantorId !== $callerId) {
    return $this->responseFactory->create(['error' => "Grant #{$id} not found."], 404);
}

Same pattern applies to POST /grants/{id}/use — return 404 if caller is not the grantee.


Multi-party confusion prevention

ScenarioExpected
Grantor calls POST /grants/{id}/use (own grant)404 — grantor is not the grantee
Grantee calls DELETE /grants/{id}404 — grantee is not the grantor
User 3 calls either on a grant between user 1 and 2404 — IDOR
X-User-Id: 0 or X-User-Id: -1401 — non-positive IDs rejected
Missing X-User-Id401

Security checklist (ATK-01 through ATK-12)

#Attack vectorMitigation
ATK-01Expired grant (clock-boundary)isExpired() comparison; DB expires_at back-dated in test
ATK-02Revoked grant state bypassisRevoked() check before use
ATK-03Self-grant (grantor == grantee)App-layer 422 + DB CHECK
ATK-04Wrong grantee uses grant (IDOR)404, not 403
ATK-05Non-grantor revokes grant (IDOR)404, not 403; original grant remains active
ATK-06Past expires_at on creationstrtotime($expiresAt) <= strtotime($now) → 422
ATK-07Type confusion on grantee_idis_int() strict check; rejects "2", null, true, 2.5
ATK-08Path traversal in resourceOpaque storage; no filesystem access
ATK-09SQL injection in resource/scopeParameterised queries; scope enum rejects injected value
ATK-10Unicode/BIDI in resourceStored verbatim; homoglyph and BIDI are distinct resources
ATK-11Double revoke (state-machine)409 on second revoke; revoked_at immutable after first
ATK-12Grantor uses own grant as grantee404 — party roles enforced strictly

Testing approach

  • ATK-01, ATK-02: Force DB state directly (UPDATE grants SET expires_at/revoked_at) to simulate time-travel without sleeping.
  • ATK-07: Test "2" (string), null, true, 2.5 (float) — not 2.0 (indistinguishable from int after PHP json_encode).
  • ATK-10: Use "\u{202E}" (BIDI override) and Cyrillic homoglyphs to confirm verbatim storage.
  • ATK-11: Assert revoked_at value is unchanged in DB after second revoke attempt.

What NOT to do

Anti-patternRisk
No UNIQUE (grantor_id, grantee_id, resource)Same pair can create duplicate grants; grantee has stale and active grants for same resource
Hard delete on revocationLoses audit history; cannot tell when access was removed or how many times it was used
Return 403 instead of 404 for ownership checkReveals grant existence to unauthorized callers; IDOR enumeration surface
No CHECK (grantor_id != grantee_id)Defense-in-depth missing; self-grants could slip through if app-layer check is bypassed
Accept free-form scope stringTypos silently default to read; use GrantScope::tryFrom() to reject unknown values
Scope check without satisfies() hierarchywrite user must separately pass read checks; use hierarchy to check all lower levels
No max TTL on expires_atGrantor creates 100-year grants; effectively permanent access with no review
No resource length limit10MB resource string causes slow index lookups and memory allocation
Check expiry before revocationRevoked + expired grant should show "revoked" — revocation wins in state machine
Track used_count client-sideClient reports usage count; server must own the counter

Released under the MIT License.