rbac

formplan v0.1

Status

Updated on 2026-06-12 to align the document with the current policy API, the ORM .permissions() integration pattern, and the mocked integration coverage.

To-do

Log

2026-06-09: Reconciled the plan with the current repo state. The plan now tracks the small policy-primitive slice that exists in code, plus the mocked integration-style test coverage, and leaves the larger py4web/RBAC adapter work as follow-up scope.

2026-06-12: Updated the plan after adding the ORM .permissions() seam to the examples and tests. The current implemented slice is now documented as injectable policy primitives plus domain-mixin-owned authorized wrapper conventions, rather than a library-owned RBAC or wrapper framework.

Current Implementation

The repository currently implements a minimal, framework-agnostic policy layer rather than the full py4web/RBAC adapter stack described below.

  • AuthorizationResult is the shared allow/deny base type, with Allow/Deny concrete results.
  • BasePolicy provides require_allowed(), and require_authenticated().
  • The documented and tested integration seam is that the domain-mixin layer starts from an ORM-provided operation such as .permissions(update=True), then lets policy return an Allow(...) transform that narrows that base query.
  • The mocked integration test in tests/test_policy.py exercises injected auth, update authorization, and publish authorization through module-style request wrappers.
  • The remaining sections describe the broader target shape and should be treated as future work unless they are implemented in code. In particular, library-owned authorized wrapper classes, field-level mutation rules, and framework-specific RBAC adapters are not implemented in src/endow today.

Goal

The goal is to keep business logic inside shared_code framework-agnostic, while still allowing backend functions to know who they are running for and what that actor may see or do.

The broader target design below remains aspirational; the current repo slice only covers the policy primitive layer and the mocked integration flow described above.

The design should support:

  • py4web controllers using the existing auth/RBAC integration.
  • Non-py4web execution contexts, such as async PGQ workers.
  • User-bound execution, for example “run this backend function as user X”.
  • User-less/system execution.
  • Optional anonymous/public execution.
  • Module-local business logic.
  • Module-local authorization policy logic.
  • Typed service injection without repeated constructor wiring or manual self.mail = mail boilerplate.

Repository Shape

The shared core keeps the original high-level split.

shared_code/
  modules/
    products/
      __init__.py
      module.py
      policy.py
      types.py

    users/
      __init__.py
      module.py
      policy.py
      types.py

  data_model/
    __init__.py
    product.py
    user.py

  services/
    mail/
      contracts.py
      smtp.py
      fake.py

    storage/
      contracts.py
      s3.py
      fake.py

    payment_api/
      contracts.py
      real.py
      fake.py

  migrations/

  tests/
    test_products.py
    test_product_policy.py
    test_product_model.py
    test_mail.py

Framework-specific code remains outside shared_code.

py4web_app/
  models/
    db.py
    auth.py
    backend.py

  controllers/
    default.py

  fixtures/
    shared_errors.py

shared_code does not get a top-level backend.py and does not get a top-level auth/ package. The context and backend concepts can live where they naturally fit in the existing structure, for example near module infrastructure or app wiring.

Core Design

Instead of passing a user, user ID, or context object through every backend function, enter shared code with a backend/module object that already has access to an auth context.

Conceptually:

auth_ctx = AuthContext.from_py4web(auth, db)
backend = Backend.with_injected(auth=auth_ctx, db=db)

products = backend.products.list()

Or, without a larger backend wrapper:

auth_ctx = AuthContext.from_py4web(auth, db)

products = Products.with_injected(
    auth=auth_ctx,
    db=db,
).list()

The important idea is:

Initialize authorization access at the boundary.
Expose it to backend/module instances through injection.
Call business methods without repeatedly passing ctx/user/services.

The framework decides the lifetime model. A concrete AuthContext may be a fixed actor snapshot, a lazy request-bound resolver, or an object refreshed at each request boundary before use.

Auth Context

The auth context should not be defined by a single lifetime strategy.

Instead, shared code should define a small framework-agnostic authorization protocol that can integrate with the existing agnostic RBAC system and resolve permissions dynamically.

Conceptual API:

@runtime_checkable
class AuthContext(Protocol):
    def can(...) -> bool: ...
    def require(...) -> None: ...
    def has_membership(...) -> bool: ...
    def has_permission(...) -> bool: ...
    def visibility_scope(...) -> object: ...

The implementation may delegate to the existing RBAC object/fixture, including recursive roles and permissions.

Shared code should depend only on that protocol. The py4web layer can satisfy it with an adapter that knows how to talk to py4web auth/session state and the existing RBAC integration.

class Products(Domain):
    auth: AuthContext


auth_ctx = SomePy4webAuthAdapter(
    auth=auth,
    db=db,
    rbac=rbac,
)

That adapter may internally use a fixed actor snapshot, lazy request-bound lookups, or explicit refresh per request.

This lets shared-code modules ask authorization questions without depending on py4web itself.

The same context should also be able to back existing py4web-oriented helpers such as requires_membership(...), so the framework fixture remains thin while the actual membership and permission logic lives in shared code.

The auth context should support at least these actor modes:

User actor       authenticated user, using their RBAC permissions
System actor     system/background execution with explicit system capabilities
Anonymous actor  optional/public execution with limited permissions

Prefer an explicit anonymous context over plain None, because it keeps authorization behavior consistent and avoids accidental unrestricted access.

Modules And Policies

Feature modules remain responsible for business flow.

Example:

shared_code/modules/products/module.py

Responsibilities:

Products.list()
Products.get(product_id)
Products.update(product_id, data)
Products.generate_update_email(...)

Authorization logic that is specific to products lives next to the module.

Example:

shared_code/modules/products/policy.py

Responsibilities:

Can this actor view this product?
Can this actor update this product?
What product visibility restriction applies to list/search operations?

The policy should not produce the whole business query and should not hand back raw ORM fragments. Instead, it should return an opaque authorization decision that the module turns into an operation-specific authorized wrapper such as request_read() or request_update().

Conceptual read flow:

Products.list()
  -> ask ProductPolicy for read authorization decision
  -> call request_read() to obtain an AuthorizedRead wrapper
  -> add product filters/search/sort on that wrapper
  -> data_model applies the scope to the ORM in one place
  -> return result

Conceptual write flow:

Products.update(product_id, data)
  -> call request_update() to obtain an AuthorizedUpdate wrapper
  -> narrow to the target row with .where(id=product_id)
  -> load the candidate row through authorized access when row-state matters
  -> perform any row-state checks needed by ProductPolicy
  -> validate business rules
  -> write changes through the wrapper
  -> optionally call services
  -> return result

This keeps product behavior in the product module, while keeping authorization rules discoverable and testable.

Py4web Initialization

In py4web, initialize the auth context and backend/module objects at the framework edge.

For now, this can live in models/backend.py.

# py4web_app/models/backend.py

auth_ctx = AuthContext.from_py4web(
    auth=auth,
    db=db,
    rbac=rbac,
)

backend = Backend.with_injected(
    auth=auth_ctx,
    db=db,
)

The backend may be a long-lived startup object. In that shape, the important requirement is that the injected AuthContext resolves authorization truth in the intended way for the current request, whether that is via lazy lookups, explicit refresh, or a static actor.

The py4web auth object/fixture remains responsible for identifying the current user. The auth context bridges that into the framework-agnostic shared-code permission model.

Existing py4web helpers such as auth.requires(...) or requires_membership(...) can stay in the py4web layer, but should delegate their actual membership and permission decisions to the shared-code AuthContext protocol implementation.

The existing RBAC integration can be passed into or referenced by the adapter so shared-code modules can resolve current permissions without importing py4web controller logic or py4web auth internals.

Py4web Controller Example

Controllers remain thin.

# py4web_app/controllers/default.py

@action("products")
@action.uses(auth.user, shared_error_fixture)
def products_index():
    products = backend.products.list()
    return dict(products=products)


@action("products/<product_id:int>", method="POST")
@action.uses(auth.user, shared_error_fixture)
def products_update(product_id):
    data = ProductUpdate.from_request(request)

    product = backend.products.update(
        product_id=product_id,
        data=data,
    )

    return dict(product=product)

The controller does not manually perform product-specific permission checks. Those checks move into shared_code/modules/products/policy.py.

The controller also does not manually construct services.

It should not need to do this:

Products(
    auth=auth_ctx,
    db=db,
    mail=Mailer.from_env(),
    storage=Storage.from_env(),
)

Instead, services are injected by the backend/module factory:

Products.with_injected(
    auth=auth_ctx,
    db=db,
)

Or:

Backend.with_injected(
    auth=auth_ctx,
    db=db,
)

Py4web Read Data Stream

HTTP request
  -> py4web route in controllers/default.py
  -> py4web auth/session identifies current user
  -> AuthContext.from_py4web(...) integrates with existing RBAC object/fixture
  -> long-lived backend uses its injected AuthContext for this request
  -> backend.products.list()
  -> Products asks ProductPolicy for a read authorization decision
  -> ProductPolicy asks auth_ctx / RBAC resolver
  -> Products calls request_read() to obtain an AuthorizedRead wrapper
  -> Products narrows that wrapper with business filters/search/sort
  -> data_model/product.py applies auth scope to the ORM in one place
  -> database
  -> results returned to controller
  -> HTTP response

Py4web Write Data Stream

HTTP request
  -> py4web route in controllers/default.py
  -> py4web auth/session identifies current user
  -> AuthContext.from_py4web(...) integrates with existing RBAC object/fixture
  -> long-lived backend uses its injected AuthContext for this request
  -> backend.products.update(product_id, data)
  -> Products asks ProductPolicy for an update authorization decision
  -> ProductPolicy asks auth_ctx / RBAC resolver
  -> Products calls request_update() to obtain an AuthorizedUpdate wrapper
  -> Products narrows to product_id through authorized access
  -> if needed, Products loads the candidate row through that wrapper
  -> ProductPolicy performs row-state checks on the authorized row
  -> Products validates business rules
  -> Products writes through the same wrapper
  -> Products optionally calls injected services
  -> result returned to controller
  -> HTTP response

Non-py4web Worker Initialization

For async PGQ workers or similar framework-less jobs, reconstruct the auth context at the worker boundary.

For a job that should run as a user:

async def handle_product_update_email_job(job):
    auth_ctx = AuthContext.for_user(
        user_id=job.user_id,
        db=db,
        rbac=rbac,
        source="pgq_worker",
    )

    backend = Backend.with_injected(
        auth=auth_ctx,
        db=db,
    )

    await backend.products.send_update_email(
        product_id=job.product_id,
    )

For a system-level job:

async def handle_product_cleanup_job(job):
    auth_ctx = AuthContext.system(
        db=db,
        rbac=rbac,
        source="pgq_worker",
    )

    backend = Backend.with_injected(
        auth=auth_ctx,
        db=db,
    )

    await backend.products.cleanup_expired()

The worker does not rely on py4web request state, globals, or contextvars. It explicitly reconstructs the execution context from the job payload.

Worker Data Stream: Run as User

PGQ job payload
  -> contains user_id / actor descriptor
  -> worker starts job
  -> AuthContext.for_user(user_id, db, rbac, source="pgq_worker")
  -> Backend.with_injected(auth=auth_ctx, db=db)
  -> backend.products.some_user_bound_operation(...)
  -> Products asks ProductPolicy for permission/visibility decisions
  -> ProductPolicy asks auth_ctx / RBAC resolver
  -> same shared-code logic as py4web request
  -> optional injected service calls
  -> job completes

This allows backend behavior to be consistent between web requests and background jobs.

Worker Data Stream: Run as System

PGQ job payload
  -> indicates system execution
  -> worker starts job
  -> AuthContext.system(db, rbac, source="pgq_worker")
  -> Backend.with_injected(auth=auth_ctx, db=db)
  -> backend.products.system_operation(...)
  -> Products asks ProductPolicy as needed
  -> ProductPolicy resolves system capabilities through auth_ctx / RBAC
  -> database writes/service calls
  -> job completes

System execution should still be explicit. “No user” should not automatically mean unrestricted access.

Shared-Code Errors And Py4web Error Mapping

Shared code raises framework-agnostic exceptions.

Examples:

AuthenticationRequired
PermissionDenied
NotFound
ValidationError

The py4web edge translates those into HTTP responses.

AuthenticationRequired -> HTTP 401
PermissionDenied       -> HTTP 403
NotFound               -> HTTP 404
ValidationError        -> HTTP 400 / form error

This mapping can live in a py4web fixture, for example:

py4web_app/fixtures/shared_errors.py

For single-row protected operations, a row that exists in the database but falls outside the actor's authorized scope should be treated as NotFound. PermissionDenied remains appropriate for denials that happen after authorized lookup, such as row-state or field-level restrictions.

The shared core does not raise py4web HTTP exceptions directly.

Services As Injection

Services keep their current contract shape.

Examples:

Mailer.from_env()
Storage.from_env()
PaymentApi.from_env()

The difference is that modules do not manually call those factories. Instead, service dependencies are declared on modules and injected automatically.

Example DX:

class Products(Domain):
    mail: Mailer
    storage: Storage

    def send_update_email(self, product_id: int):
        ...

Usage:

products = Products.with_injected(
    auth=auth_ctx,
    db=db,
)

products.send_update_email(product_id)

Or through the backend:

backend = Backend.with_injected(
    auth=auth_ctx,
    db=db,
)

backend.products.send_update_email(product_id)

The injected Mailer is resolved from its contract, which may internally use:

Mailer.from_env()

The module author gets:

self.mail
self.storage

without writing constructor parameters or manual assignments.

Services Depending On Other Services

Services may also declare dependencies on other services when useful.

Example DX:

class ProductExportService(Service):
    storage: Storage
    payment_api: PaymentApi

Then:

class Products(Domain):
    exporter: ProductExportService

The service injection system resolves the dependency chain.

However, this should be used carefully.

Recommended rule:

Modules may depend on services.
Services may depend on lower-level services when necessary.
Services should not depend on feature modules.
Service-to-service dependencies and cycles are allowed by the runtime.
Use checked graph construction to turn Service -> Domain dependencies into warnings or errors.

If a service begins orchestrating multiple business concepts, that is a sign the behavior may belong in a module instead.

Testing

Tests can instantiate modules or backends without py4web.

Example:

def test_products_list_respects_school_visibility(test_db):
    auth_ctx = AuthContext.for_test_user(
        user_id=1,
        db=test_db,
    )

    products = Products.with_injected(
        auth=auth_ctx,
        db=test_db,
    )

    result = products.list()

    assert ...

Or through the backend:

def test_products_update_requires_permission(test_db):
    auth_ctx = AuthContext.for_test_user(
        user_id=1,
        db=test_db,
    )

    backend = Backend.with_injected(
        auth=auth_ctx,
        db=test_db,
    )

    with pytest.raises(PermissionDenied):
        backend.products.update(product_id=123, data=...)

Tests remain framework-agnostic: AuthContext.for_test_user covers the test RBAC setup, and service contracts resolve through their normal from_env behavior in test context.

Authorized Wrappers

The preferred backend shape is to expose protected data only through operation-specific request methods on the module or module base object.

request_read() -> AuthorizedRead[T]
request_update() -> AuthorizedUpdate[TEdit]
request_delete() -> AuthorizedDelete[T]
request_create() -> AuthorizedCreate[TCreate]

These request methods are the connection point between module-local policy and a restricted table/query object. Domain code does not receive a raw protected table.

Each wrapper delegates to the real ORM/query builder but exposes only a safe subset of methods. The core invariant is monotonic narrowing: the wrapper starts with the maximum scope permitted for the current actor, and later domain filters may only reduce that set.

authorized_scope AND domain_filters

Key rules:

  • Module-local policy decides the authorization boundary and operation-specific rules.
  • request_*() turns that decision into an opaque authorized wrapper.
  • The wrapper is the one place where the auth scope is attached to the real ORM object.
  • Protected domain code should not receive raw table or raw query-builder access by default.
  • For row-bound reads, updates, and deletes, rows outside authorized scope collapse to NotFound.

Operation notes:

  • AuthorizedRead may allow where, select, paginate, count, and safe ordering, while enforcing readable-field policy.
  • AuthorizedUpdate should enforce both scoped row access and field-level write rules at execution time.
  • Some writes need row-state checks in addition to scope checks, so the design should support loading a candidate row through authorized access and then applying a policy decision before mutating it. Concretely: narrow the authorized wrapper to the target row, fetch through that wrapper, run the row-state policy check, and then mutate through the same wrapper.
  • AuthorizedCreate should validate or normalize submitted fields, enforce required or forced values, and support policy-level relation constraints such as “gid must already exist as an identity”.

If reality forces occasional raw database access, treat it as an explicit escape hatch rather than a normal API. A private attribute such as ._db is acceptable only with a strict lint rule or documented waiver so each use must explain why the wrapper API was insufficient.

Resulting Pattern

The final architecture becomes:

py4web / worker / test boundary
  -> create AuthContext
  -> create Backend or module with injected services
  -> call feature module method
  -> module asks local policy for an authorization decision
  -> policy delegates recursive permission resolution to auth_ctx/RBAC
  -> module obtains an operation-specific authorized wrapper
  -> module performs business logic and persistence through that wrapper
  -> module calls injected services when needed
  -> framework edge maps shared-code errors to framework-specific responses

This gives the desired balance:

No global current user
No repeated ctx argument everywhere
No fat User object with product methods
No manual service construction in controllers
No py4web dependency in shared_code
Product behavior stays in products module
Permission rules stay near the relevant module
Workers can run as user or system
Tests can run outside py4web

Authorization Boundary

Resolved Design Note

The main authorization risk was previously that a policy could hand back a visibility restriction while the module still composed the final query manually. That shape made it too easy for one code path to forget to apply the restriction consistently.

The chosen resolution is to keep the policy output opaque and keep scope application in one place. In practice: policy decides, request_*() creates an authorized wrapper, and data_model applies the scope to the ORM.

Conceptual shape:

class Products(Domain):
    def list(self, filters):
        return (
            self.request_read()
            .where(filters)
            .select(...)
        )

    def update(self, product_id, data):
        return (
            self.request_update()
            .where(id=product_id)
            .update(**data)
        )

    def create(self, data):
        return self.request_create().insert(data)
                    

This keeps the authorization boundary explicit, avoids raw query fragments leaking out of policy code, and makes it harder for a caller to skip authorization on a single read or write path while preserving the module-centric design.

The remaining deliberate compromise is that a private raw-database escape hatch may still exist for rare cases where the wrapper API is insufficient, but every such use should be lint-gated and justified explicitly.