Skip to content

Reachitect to support bulk authz with FactSource the bottom of ReBAC #20

@hardbyte

Description

@hardbyte

Thesis

FactSource should become the bottom of the ReBAC layer rather than landing alongside RelationshipResolver.

A relationship check is just a typed fact load:

RelationshipQuery<SubjectId, ResourceId, Relation> -> bool

Keeping both RelationshipResolver::{has_relationship, has_relationship_batch} and FactSource::load_many would split batching, caching, max-batch hints, error handling, and tracing across two abstractions that are doing the same job.

Proposed shape

Fact keys and sources

pub trait FactKey: Eq + Hash + Clone + Send + Sync + 'static {
    type Value: Clone + Send + Sync + 'static;
    const NAME: &'static str; // tracing/diagnostics only, not the registry key
}

pub enum FactLoadError {
    SourceNotRegistered { fact_name: &'static str },
    SourceContractViolation { fact_name: &'static str, expected: usize, actual: usize },
    LoaderCancelled { fact_name: &'static str },
    Backend(Arc<dyn std::error::Error + Send + Sync>),
}

pub enum FactLoadResult<V> {
    Found(V),
    Missing,
    Error(FactLoadError),
}

#[async_trait]
pub trait FactSource<K: FactKey>: Send + Sync {
    async fn load_many(&self, keys: &[K]) -> Vec<FactLoadResult<K::Value>>;
    fn max_batch_size(&self) -> Option<NonZeroUsize> { None }
}

FactSource::load_many receives keys that are unique within the call. It must return exactly one result per input key, in input order. The session deduplicates before calling the source, chunks by FactSource::max_batch_size, caches results/errors for the request, and expands back to caller-visible order including duplicates.

Session

pub struct EvaluationSession { /* type-indexed sources, caches, in-flight loads */ }

The session is per request/authorization pass and is passed by shared reference. It owns request-scoped caches and a type-indexed source registry keyed by TypeId, not by FactKey::NAME.

A missing source returns FactLoadResult::Error(FactLoadError::SourceNotRegistered { .. }); policies map that to denied. Cached errors live only for the session lifetime and do not survive into later requests.

Concurrent get(K)/get_many([K]) calls for an in-flight key should join the existing load rather than firing a second backend call. If the loader task is cancelled or panics before it completes, a drop guard must remove the in-flight entries, cache Error(LoaderCancelled), and wake waiters. This keeps the DataLoader property even before policy evaluation itself becomes concurrent.

Register sources during session setup. Replacing a source while keys for the same type are in flight is unsupported and should be documented/debug-asserted.

Policy API

Make the v0.3 break clean: one single-item entry point and one batch entry point.

#[async_trait]
pub trait Policy<S, R, A, C>: Send + Sync {
    async fn evaluate(&self, ctx: &EvalCtx<'_, S, R, A, C>) -> PolicyEvalResult;

    async fn evaluate_batch<'i>(&self, ctx: &BatchEvalCtx<'i, S, R, A, C>)
        -> Vec<PolicyEvalResult>
    {
        /* default point-loop */
    }

    fn policy_type(&self) -> &str;
}

pub struct EvalCtx<'a, S, R, A, C> {
    pub subject:  &'a S,
    pub action:   &'a A,
    pub resource: &'a R,
    pub context:  &'a C,
    pub session:  &'a EvaluationSession,
}

pub struct BatchEvalCtx<'i, S, R, A, C> {
    pub subject: &'i S,
    pub action:  &'i A,
    pub items:   &'i [PolicyBatchItem<'i, R, C>],
    pub session: &'i EvaluationSession,
}

All checker evaluation methods should require an explicit session. RBAC/ABAC-only callers use EvaluationSession::empty(). This avoids the mixed-stack footgun where a convenience method silently creates an empty source registry and denies every ReBAC check.

PermissionChecker::with_max_batch_size remains a defensive cap on the number of pending items sent to each policy batch call. FactSource::max_batch_size is the source-level cap after session deduplication.

ReBAC

#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub struct RelationshipQuery<SubjectId, ResourceId, Relation> {
    pub subject_id: SubjectId,
    pub resource_id: ResourceId,
    pub relation: Relation,
}

pub struct RebacPolicy<S, R, A, C, SubjectId, ResourceId, Relation> {
    subject_id: Arc<dyn Fn(&S) -> SubjectId + Send + Sync>,
    resource_id: Arc<dyn Fn(&R) -> ResourceId + Send + Sync>,
    relation: Relation,
}

RebacPolicy extracts flat IDs synchronously, builds RelationshipQuery, and loads through EvaluationSession. Async extraction is intentionally unsupported; if extracting the ID needs a backend call, model that as another FactSource.

Decision mapping:

  • Found(true) -> granted
  • Found(false) -> denied, no matching relationship
  • Missing -> denied, relationship fact missing
  • Error(SourceNotRegistered | SourceContractViolation | LoaderCancelled | Backend(_)) -> denied, fact load failed

RBAC stays in memory in v0.3. Moving role lookup onto FactSource<RoleFactKey> can be a mechanical follow-up if a real backend-backed role source appears.

LookupSource carve-out

Do not make unbounded Vec<RId> the canonical graph lookup shape. Reserve a paginated/streaming lookup surface, but do not require implementations in v0.3.

pub struct LookupPage<ResourceId, Cursor> {
    pub resources: Vec<ResourceId>,
    pub next_cursor: Option<Cursor>,
}

#[async_trait]
pub trait LookupSource<SId, RId, Re>: FactSource<RelationshipQuery<SId, RId, Re>> {
    type Cursor: Clone + Send + Sync + 'static;

    async fn lookup_resources(
        &self,
        subject: &SId,
        relation: &Re,
        cursor: Option<Self::Cursor>,
        limit: Option<NonZeroUsize>,
    ) -> Result<LookupPage<RId, Self::Cursor>, LookupError>;
}

Tracing

A batch fact load should appear once in tracing with a session-scoped load id and key count. Per-item policy decisions should remain separate so an audit can connect one shared load to many decisions without logging N duplicate backend loads.

Removed from the v0.2 shape

  • RelationshipResolver
  • RelationshipResolver::has_relationship
  • RelationshipResolver::has_relationship_batch
  • ReBAC resolver fields that take &R
  • checker evaluation methods that create an implicit empty session
  • Policy::evaluate_access / Policy::evaluate_access_batch
  • policy_type(&self) -> String

Migration from 0.2

  • Custom Policy::evaluate_access(...) impls become Policy::evaluate(&self, ctx: &EvalCtx<...>).
  • Custom batch overrides become Policy::evaluate_batch(&self, ctx: &BatchEvalCtx<...>).
  • Callers create a request session and pass it to checker evaluation. RBAC/ABAC-only code can use EvaluationSession::empty().
  • Custom RelationshipResolver impls become FactSource<RelationshipQuery<SubjectId, ResourceId, Relation>> impls.
  • ReBAC construction changes from resolver-plus-relation to subject/resource ID extractors plus relation.

Acceptance criteria

  • FactSource::load_many is documented and tested as unique-key input, exact-length output, input-order output.
  • EvaluationSession::get_many preserves caller order and duplicates while deduplicating source calls.
  • Concurrent requests for the same in-flight key join one source load.
  • Cancellation or panic of the leader load task wakes waiters and returns Error(LoaderCancelled) instead of hanging.
  • Missing source, backend error, missing fact, and wrong source result count all fail closed with distinct reasons.
  • Session chunking by FactSource::max_batch_size preserves global ordering and duplicate expansion.
  • RebacPolicy no longer depends on RelationshipResolver or &R-based resolver APIs.
  • Checker evaluation requires an explicit EvaluationSession.
  • policy_type returns &str.
  • LookupSource is paginated/streaming-shaped and optional.
  • The PostgreSQL 18 bulk ReBAC example implements FactSource<RelationshipQuery<Uuid, Uuid, String>>, fail-closes backend errors, and shows point-query vs bulk-query behavior.
  • Contract tests include session unit tests, boxed dyn Policy batch dispatch, OR pending-set short-circuiting, and proptest coverage for ordering/dedup/chunking.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions