rbac
formplan v0.1Status
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 application-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.
-
AuthorizationResultis the shared allow/deny base type, withAllow/Denyconcrete results. -
BasePolicyprovidesrequire_allowed(), andrequire_authenticated(). -
The documented and tested integration seam is that
application/domain code starts from an ORM-provided
operation such as
.permissions(update=True), then lets policy return anAllow(...)transform that narrows that base query. -
The mocked integration test in
tests/test_policy.pyexercises 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/endowtoday.
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 = mailboilerplate.
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:
-
AuthorizedReadmay allowwhere,select,paginate,count, and safe ordering, while enforcing readable-field policy. -
AuthorizedUpdateshould 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.
-
AuthorizedCreateshould 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.