Sistem otorisasi berbasis relasi (Relationship-Based Access Control / ReBAC) memodelkan relasi akses seperti kepemilikan dokumen atau keanggotaan grup dalam bentuk directed graph. Masalah muncul ketika data relasi mengandung dependensi sirkular (misal: Tim A adalah anggota Tim B, dan Tim B adalah anggota Tim A). Traversal tanpa batas memicu recursive loop, menghabiskan work_mem, memicu PostgreSQL statement_timeout, dan menghasilkan respons HTTP 500 yang menutupi kesalahan struktur data klien.

Solusi: definisikan kontrak API otorisasi yang ketat dengan pembatasan kedalaman (depth boundary), gunakan SQL/PGQ berbatas deterministik, dan petakan deteksi siklus langsung ke HTTP 422.

Spesifikasi Kontrak API Evaluasi Izin

Endpoint evaluasi izin harus menetapkan boundary parameter yang tegas. Klien tidak boleh diizinkan meminta traversal tanpa batas. Evaluasi dilakukan via endpoint POST /v1/auth/check.

Request Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["subject", "relation", "object"],
  "additionalProperties": false,
  "properties": {
    "subject": {
      "type": "string",
      "pattern": "^[a-z_]+:[a-zA-Z0-9_-]+$",
      "maxLength": 128
    },
    "relation": {
      "type": "string",
      "enum": ["viewer", "editor", "owner", "member"]
    },
    "object": {
      "type": "string",
      "pattern": "^[a-z_]+:[a-zA-Z0-9_-]+$",
      "maxLength": 128
    },
    "max_depth": {
      "type": "integer",
      "minimum": 1,
      "maximum": 10,
      "default": 5
    }
  }
}

Response Schema: Sukses vs Traversal Error

Respons sukses menunjukkan status evaluasi:

{
  "allowed": true,
  "depth_evaluated": 3
}

Respons ketika terdeteksi siklus relasi atau kedalaman melebihi batas (HTTP 422 Unprocessable Entity):

{
  "error": {
    "code": "CYCLIC_RELATION_DETECTED",
    "message": "Traversal dihentikan: terdeteksi loop pada rantai relasi.",
    "details": {
      "culprit_node": "team:alpha",
      "visited_path": ["user:101", "team:beta", "team:alpha", "team:beta"]
    }
  }
}

Skema DDL Postgres Property Graph (SQL/PGQ)

PostgreSQL 16+ mengadopsi elemen SQL:2023 SQL/PGQ. Buat tabel relasional dasar beserta deklarasi PROPERTY GRAPH.

CREATE TABLE entities (
    id VARCHAR(128) PRIMARY KEY,
    entity_type VARCHAR(32) NOT NULL
);

CREATE TABLE relations (
    source_id VARCHAR(128) NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
    relation VARCHAR(32) NOT NULL,
    target_id VARCHAR(128) NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
    PRIMARY KEY (source_id, relation, target_id)
);

CREATE INDEX idx_relations_lookup ON relations (source_id, relation, target_id);
CREATE INDEX idx_relations_reverse ON relations (target_id, relation, source_id);

-- Definisi Property Graph SQL/PGQ
CREATE PROPERTY GRAPH auth_graph
VERTEX TABLES (
    entities
        KEY (id)
        PROPERTIES (id, entity_type)
)
EDGE TABLES (
    relations
        KEY (source_id, relation, target_id)
        SOURCE KEY (source_id) REFERENCES entities(id)
        DESTINATION KEY (target_id) REFERENCES entities(id)
        PROPERTIES (relation)
);

Query Traversal Berbatas Deterministik & Deteksi Siklus

Gunakan bounded quantifier {1, 5} pada sintaks GRAPH_TABLE untuk membatasi kedalaman traversal. Jika platform engine belum mengaktifkan SQL/PGQ runtime secara penuh, gunakan ANSI Recursive CTE ekuivalen berikut yang menerapkan deteksi siklus via array tracking:

WITH RECURSIVE traverse AS (
    -- Anchor member
    SELECT 
        r.source_id,
        r.target_id,
        1 AS depth,
        ARRAY[r.source_id] AS path,
        false AS is_cycle
    FROM relations r
    WHERE r.source_id = $1 AND r.relation = $2

    UNION ALL

    -- Recursive member dengan batas depth dan deteksi array
    SELECT 
        nxt.source_id,
        nxt.target_id,
        t.depth + 1,
        t.path || nxt.source_id,
        nxt.source_id = ANY(t.path) AS is_cycle
    FROM relations nxt
    JOIN traverse t ON t.target_id = nxt.source_id
    WHERE t.depth < $3 -- Boundary: max_depth deterministik
      AND NOT t.is_cycle
)
SELECT 
    depth,
    path || target_id AS full_path,
    is_cycle
FROM traverse
WHERE target_id = $4 OR is_cycle = true
LIMIT 1;

Kueri di atas menjamin mesin database berhenti mengevaluasi cabang saat node yang sama dikunjungi ulang (nxt.source_id = ANY(t.path)) atau saat kedalaman melebihi parameter batas (t.depth < $3).

Strategi Error Handling: HTTP 422 vs HTTP 500

Kegagalan traversal akibat referensi siklis bukan kesalahan infrastruktur (Internal Server Error / 500). Kondisi ini mengindikasikan inkonsistensi logis data pada domain relasi yang dikirim klien atau tersimpan di database.

  • Masalah HTTP 500: statement_timeout memutus koneksi worker. Metrik alerting reliability (SLO/SLA) terpicu palsu, dan pool koneksi Postgres terkuras oleh query loop yang berjalan hingga ambang timeout.
  • Solusi HTTP 422: Kueri traversal secara eksplisit memproyeksikan status is_cycle. Application layer membaca flag tersebut dan langsung mengembalikan kode status 422 Unprocessable Entity beserta path siklusnya ke klien.

Runnable Check Sederhana (Node.js)

Script mandiri tanpa dependensi eksternal untuk menguji boundary validation dan algoritma pendeteksi siklus pada kontrak data graph.

import assert from 'node:assert';

// ponytail: deteksi path linear, ganti ke Tarjan/SCC jika butuh analisis graph statis batch.
function evaluateGraph(relations, source, target, maxDepth) {
  if (maxDepth < 1 || maxDepth > 10) {
    throw new RangeError('INVALID_DEPTH_BOUNDARY');
  }

  const queue = [{ current: source, depth: 0, path: [source] }];

  while (queue.length > 0) {
    const { current, depth, path } = queue.shift();

    if (current === target) {
      return { allowed: true, depth };
    }

    if (depth >= maxDepth) continue;

    const nextNodes = relations[current] || [];
    for (const next of nextNodes) {
      if (path.includes(next)) {
        return {
          error: 'CYCLIC_RELATION_DETECTED',
          path: [...path, next]
        };
      }
      queue.push({ current: next, depth: depth + 1, path: [...path, next] });
    }
  }

  return { allowed: false, depth: maxDepth };
}

// --- TEST CASES ---
const graph = {
  'user:1': ['team:engineering'],
  'team:engineering': ['team:core'],
  'team:core': ['team:engineering'], // Cycle intentionally injected
  'team:qa': ['doc:report']
};

// 1. Boundary depth check
assert.throws(() => evaluateGraph(graph, 'user:1', 'doc:report', 15), RangeError);

// 2. Traversal cycle detected -> maps to 422 payload logic
const cycleResult = evaluateGraph(graph, 'user:1', 'doc:report', 5);
assert.strictEqual(cycleResult.error, 'CYCLIC_RELATION_DETECTED');
assert.deepStrictEqual(cycleResult.path, ['user:1', 'team:engineering', 'team:core', 'team:engineering']);

// 3. Normal deny without cycle
const deniedResult = evaluateGraph(graph, 'user:1', 'team:qa', 2);
assert.strictEqual(deniedResult.allowed, false);

console.log('Semua assertion berhasil diverifikasi.');

evaluateGraph → skipped: persistent graph cache, add when QPS exceeds 10k/s.