返回资讯中心
外部精选
软件工程
#开发者实时流

Pemba – An embedded Zanzibar permission engine that shares your DB transactions

pemba was formerly developed under the codename warden.

Hacker News 最新Joshua-Fields2026/10/29 · 2 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

pemba was formerly developed under the codename warden.

pemba is an embedded, Zanzibar-style authorisation engine written in Rust, with Python bindings: relationship-based access control (ReBAC) that runs inside your process and keeps its data in your database. Think "SQLite for authorisation".

You describe your permission model in a schema (a strict subset of SpiceDB's schema language), write relationships such as document:readme#viewer@user:anne, and ask questions: can Anne view the readme?, which documents can Anne view?, who can view the readme? Answers follow groups, folders, nested teams and exclusions, and every answer names the revision it was computed at.

Because the engine is in-process, a relationship write can join your application's own database transaction: the row you insert and the relationship that grants access to it commit or roll back together. There is no second system to keep in sync, so no outbox, no reconciliation job, and no window where a document exists that nobody can open.

Status: alpha, 0.1.0-alpha.4. The API may change. check, lookup_resources, lookup_subjects, the memory, SQLite and Postgres stores, transactional writes and the Python bindings are implemented and tested, including against SpiceDB. Until 0.1.0, expect breaking changes.

Try it in the browser: the playground runs the real engine, compiled to WebAssembly, in your browser. Write a schema and relationships, run checks and lookups, see why an answer was reached, and share a link. Nothing you type leaves your machine.

pip install --pre pemba # Python 3.11 or newer; includes Postgres cargo add pemba@0.1.0-alpha.4 # Rust library cargo add pemba-store-postgres@0.1.0-alpha.4 # Rust: the Postgres store cargo install pemba-cli@0.1.0-alpha.4 # pemba command-line tool

Only pre-releases exist so far, so pip needs --pre and cargo needs the explicit version.

Python (Rust 1.99, pinned in rust-toolchain.toml):

git clone https://github.com/Joshua-Fields/pemba cd pemba/crates/pemba-py python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate pip install maturin maturin develop --release

Rust:

[dependencies] pemba = { git = "https://github.com/Joshua-Fields/pemba" }

The pemba crate re-exports the engine and both stores (features sqlite and memory, both on by default).

import sqlite3 from pemba import Engine

SCHEMA = """ definition user {}

definition document { relation owner: user relation viewer: user permission view = viewer + owner } """

engine = Engine.sqlite("app.db") # or Engine.memory() engine.write_schema(SCHEMA) # or open("authz.zed").read()

rev = engine.write(touch=["document:readme#viewer@user:anne"]) engine.check("document:readme", "view", "user:anne", at_least=rev) # True engine.check("document:readme#view@user:bob") # single-string form

engine.lookup_resources("document", "view", "user:anne") # LookupResult(ids=[...], cursor=None, revision=..) engine.lookup_subjects("document:readme", "view", "user") # SubjectsResult(ids, wildcard, excluded, revision)

transactional with app data (headline feature)

conn = sqlite3.connect("app.db", isolation_level=None) conn.execute("CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, title TEXT NOT NULL)") conn.execute("BEGIN IMMEDIATE") conn.execute("INSERT INTO documents(id, title) VALUES (?, ?)", ("readme", "Readme")) pending = engine.write(touch=["document:readme#owner@user:anne"], conn=conn) conn.execute("COMMIT") engine.check("document:readme#view@user:anne", at_least=pending) # True

Paste it into a file and run it: it needs nothing else in the directory, and CI runs it verbatim in an empty one. Every method has a docstring, and the package ships type stubs that pass mypy --strict.

use pemba::model::{Consistency, WriteOp, WriteRequest}; use pemba::{Engine, LookupResourcesRequest, MemoryStore};

fn main() -> Result<(), Box<dyn std::error::Error>> { let engine = Engine::builder().store(MemoryStore::new()).build()?; engine.write_schema( "definition user {} definition document { relation viewer: user relation editor: user permission view = viewer + editor }", )?;

let rev = engine.write(WriteRequest::new(vec![WriteOp::Touch("document:readme#editor@user:anne".parse()?)]))?;

let allowed = engine .check_str("document:readme", "view", "user:anne", Consistency::AtLeast(rev))?; assert!(allowed);

let page = engine.lookup_resources(LookupResourcesRequest::new( "document".parse()?, "view".parse()?, "user:anne".parse()?, ))?; assert_eq!(page.resource_ids.len(), 1); Ok(()) }

This is a complete src/main.rs after cargo add pemba@0.1.0-alpha.4, and the same example is a doctest on the pemba crate. For persistence, use SqliteStore::open("app.db", SqliteConfig::default()) in place of MemoryStore::new().

This is the headline feature. pemba's tables live in your database, prefixed pemba_, and a write can run inside a transaction you opened:

Python: pass conn= (a sqlite3.Connection on the same file, with a transaction open) to engine.write, as in the quick start. pemba neither commits nor rolls back. The write returns a PendingRevision: it becomes readable when you commit and never exists if you roll back. Pass it as at_least=, or call pending.revision() after the commit for the number. Without an open transaction, the write raises ValueError unless you pass allow_autocommit=True.

Rust: engine.write_in(&mut SqliteTxn::new(&tx), request) on your own rusqlite::Transaction (use the rusqlite version pemba-store-sqlite depends on):

let mut conn = rusqlite::Connection::open("app.db")?; let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; tx.execute("INSERT INTO documents (id, title) VALUES (?1, ?2)", ("readme", "Readme"))?; let pending = engine.write_in(&mut SqliteTxn::new(&tx), request)?; tx.commit()?; let rev = engine.resolve(&pending)?; // the revision, for Consistency::AtLeast(rev)

Resolve after the commit: on SQLite the number is known at once, but Postgres assigns it at commit and can only answer then.

Postgres: the same, with your Postgres transaction (see Postgres). In Python, conn= takes a psycopg 3 or psycopg2 connection, a SQLAlchemy Session or Connection, or django.db.connection, inside a transaction. In Rust, wrap your postgres::Transaction in PgTxn::new(&mut tx). pending.revision() raises RevisionPending before your commit and WriteRolledBack after a rollback.

Python and SQLite. Engine.sqlite() stores relationships through CPython's own sqlite3 module. It shares your interpreter's SQLite library (3.26.0 or newer) and bundles none. In the same process, open the database with sqlite3 or anything built on it (SQLAlchemy, Django, aiosqlite). Do not open it with anything that brings its own SQLite, such as apsw or pysqlite3-binary. On Linux and macOS, two SQLite libraries in one process release each other's file locks, and a committed transaction can be silently lost. See sections 2.2 and 2.3 of How To Corrupt An SQLite Database File.

pemba keeps its tables in one schema of your Postgres database (15 or newer, UTF8), every statement qualified with it, so your search_path cannot redirect them. Several stores can share a database, one schema each.

engine = Engine.postgres("postgres://app@db/app", schema="authz") # calls release the GIL

let store = PostgresStore::open("postgres://app@db/app", PostgresConfig::default().with_schema("authz"))?; let engine = Engine::builder().store(store).build()?;

From async Rust, call the engine through tokio::task::spawn_blocking: the client is blocking. From async Python, read through asyncio.to_thread(engine.check, ...), and join a transaction through AsyncSession.run_sync(lambda s: engine.write(..., conn=s)).

Migrations are a deploy step. auto_migrate defaults to off on Postgres: run PostgresStore::migrate (Rust) or open once with auto_migrate=True (Python) as the role that owns the schema, before the app starts. An app that opens unmigrated tables gets NotMigrated.

Privileges. Migrating needs CREATE on the schema. The app's role needs USAGE on the schema, SELECT, INSERT, UPDATE on its tables, USAGE on its sequences, and DELETE on pemba_relationships, pemba_pending and pemba_revisions if it runs gc. Nothing more: no superuser, no extension.

Revisions are assigned at commit. Concurrent writers commit in any order, so a write's revision is drawn when its transaction commits, and revision order is commit order. A revision drawn by a commit that then fails is skipped: numbers only go up, but can have gaps. Reading at a skipped number gives the state of the revision before it.

Don't run SET CONSTRAINTS ALL IMMEDIATE in a long transaction that writes relationships; name your own constraints instead. It makes pemba assign the revision right away, which holds a store-wide lock until that transaction ends: every other pemba commit waits for it. Django's check_constraints() does this (Django's TestCase calls it after every test). pemba still works, raises the server WARNING, and warns once per engine (pemba.PembaWarning in Python, a tracing warning in Rust).

SERIALIZABLE and REPEATABLE READ: retry with backoff. In those isolation levels a transaction can fail with a serialization failure (40001) or a deadlock (40P01), at any statement or at COMMIT. pemba reports both as the retryable TransactionConflictError (TRANSACTION_CONFLICT). Roll back and run the whole transaction again, waiting a random 0 to min(1 s, 10 ms × 2ⁿ) before attempt n. Retrying at once, or retrying only the pemba call, fails again.

import random, time import psycopg from pemba import TransactionConflictError

RETRYABLE = (TransactionConflictError, psycopg.errors.SerializationFailure, psycopg.errors.DeadlockDetected)

def with_retries(attempt, tries=10): for n in range(tries): try: return attempt() except RETRYABLE: if n == tries - 1: raise time.sleep(random.uniform(0, min(1.0, 0.01 * 2**n)))

conn = psycopg.connect(dsn, autocommit=True) conn.isolation_level = psycopg.IsolationLevel.SERIALIZABLE

def share(): with conn.transaction(): # BEGIN … COMMIT, rolled back on error conn.execute("INSERT INTO documents (id) VALUES ('readme')") return engine.write(touch=["document:readme#viewer@user:anne"], conn=conn)

pending = with_retries(share)

With SQLAlchemy, retry on TransactionConflictError and on sqlalchemy.exc.OperationalError whose orig.sqlstate is 40001 or 40P01, running with Session(db) as s, s.begin(): ... afresh each attempt.

PgBouncer. Session pooling works. Transaction pooling works too: pemba keeps no session state, but the engine's connections use prepared statements, which need PgBouncer 1.21 or newer with max_prepared_statements set.

Latency is round trips. Each statement is a round trip to the server (prepared statements are cached per connection), so cost scales with your network, not with the data. A direct check costs about 5 round trips; each level of hierarchy the check walks adds about 3, so a document 30 folders deep costs 99. A lookup_resources over 10,000 documents costs about 500. A write is 10 round trips whatever its size, up to the batch limit of 1,000. On a CI runner where a round trip took about 150 µs, that came to 0.76 ms for a direct check, 16 ms for 30 folders and 19 ms for a 1,000-tuple write (benches/RESULTS.md). Keep the database close to the app, and prefer shallow hierarchies on hot paths. Fewer round trips for deep hierarchies is planned after the first Postgres release.

Garbage collection and autovacuum. engine.gc(keep_from) raises the horizon, deletes history below it, and compacts what is left, each step in its own short transaction off your write path; run it from a scheduled job. It leaves dead rows like any bulk delete: autovacuum's defaults handle them, and after a first GC of a large backlog a manual VACUUM (ANALYZE) of <schema>.pemba_relationships reclaims space sooner. A long-open transaction anywhere in the database holds back vacuum, as always in Postgres.

Two-phase commit. pemba assigns

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →