angee.compose.permissions
Additive REBAC-schema extension seam — domain vocabulary stays in its addon.
django-zed-rebac owns permission sync: it reads one permissions.zed per installed app, parses it, and hard-errors on a definition declared twice. The SpiceDB schema language it accepts has no extend — a definition is owned, whole, by exactly one file. That makes the owning file the only place a relation can be declared, so a consumer addon that needs its own role vocabulary on another addon's resource would otherwise have to edit that addon's zed. That leaks domain vocabulary across ownership boundaries — the target addon should own the seam, each extending addon its own vocabulary.
This module is that seam. A consumer addon contributes to a definition owned by another addon through a sibling permissions.extends.zed fragment. Each definition <target> { … } block in the fragment names an existing definition and lists the relations it contributes and the permission arms it unions in. django-zed-rebac never reads the fragment (it only reads permissions.zed), so there is no duplicate-definition collision. Instead the composer merges every fragment into its target's owning package at build time, emits the merged effective zed into the runtime tree, and repoints that package's AppConfig.rebac_schema at it — so rebac sync / rebac check / reconcile_permissions all read the merged superset with no library change.
Merge semantics (deterministic; composition order is sorted by contributor package name):
- Contributed relations are appended to the target definition. A relation name already present on the base — or contributed by two fragments — is a hard collision (fail fast).
- Contributed permission arms are unioned (
+) into the base permission of the same name, contributors in sorted order. A fragment permission whose name the base does not declare is a hard error (there is no arm to extend). - Extending a definition no installed package declares is a hard error.
The merged definition's identity changes whenever any contribution changes: the relation/arm lines move the file's bytes (so angee build --check drifts) and the per-relation/permission payload hash moves (so rebac sync re-applies). The emitted header additionally records each contributor and its fragment revision in @rebac_extended_by for provenance. The base package does not bump its own @rebac_schema_revision for an additive extension — the contribution is owned, and revisioned, by the contributing addon.
Dormant by construction: with no permissions.extends.zed anywhere, every entry point returns empty / no-op and nothing is emitted or repointed.
SchemaExtensionError
class SchemaExtensionError(RuntimeError)Raised when a permissions.extends.zed fragment cannot be merged.
merged_schemas
def merged_schemas(app_configs: Iterable[AppConfig]) -> dict[str, Schema]Return {owning package -> merged full Schema} for extended packages.
Only packages whose base definitions receive a contribution appear. The merged Schema is the package's whole permissions.zed with each extended definition replaced by base + contributions; unextended definitions and caveats pass through so the emitted file is a faithful superset the library can sync (and prune against) as that package's schema.
Empty when no permissions.extends.zed exists — the dormant path.
render_zed
def render_zed(package: str, schema: Schema) -> strRender a merged Schema to deterministic zed text the library round-trips.
Byte-stable: definitions, relations, permissions, caveats and subject unions are sorted; compound permission expressions are fully parenthesised. The GENERATED_SENTINEL marks the file as build output.
merged_schema_relpath
def merged_schema_relpath(package: str) -> PathReturn the merged zed path relative to the runtime dir for package.
extension_source_map
def extension_source_map(app_configs: Iterable[AppConfig]) -> dict[Path, str]Return {runtime-relative path -> merged zed text} for emission.
Consumed by :meth:Runtime.render_sources so the merged files ride the one emit/drift/clean/sentinel lifecycle. Empty when dormant.
apply_schema_paths
def apply_schema_paths(app_configs: Iterable[AppConfig],
runtime_dir: Path) -> NoneRepoint each extended package's rebac_schema at its merged zed.
rebac sync / rebac check / reconcile_permissions resolve a package's schema as Path(app_config.path) / app_config.rebac_schema; an absolute value wins (Path('/a') / '/b' == Path('/b')), so pointing at the emitted merged file makes every reader see the superset. No-op when dormant.