RBAC enforcement patterns
This document declares how the Flask application enforces authorization. It names every supported enforcement pattern. Every endpoint declares one policy built from these patterns. The authorization model itself — permissions, roles, scopes, grants — is defined in rbac-authorization-model.md.
Principles
- Every endpoint declares a policy. An endpoint with no declared policy fails continuous integration. Public endpoints and authenticated-only endpoints declare that status explicitly. A missing declaration is an error, never a default.
- The caller identity always participates. Every authorization decision evaluates the calling principal. The exceptions are the endpoints where no principal exists — public endpoints and token-triggered background endpoints — and each of those declares that no decision exists.
- One declaration mechanism. All endpoints declare policy through the same mechanism. Reviewers and analysis tools read one format.
- The engine resolves scope. The endpoint names the object the operation touches. The authorization engine resolves the object's workspace and parent chain using the documented object scope hierarchy. Endpoints never look up workspace membership themselves. Creation is the one exception, because the object does not exist yet.
- Policy is extractable. The complete endpoint-to-policy table can be produced mechanically, without reading handler code.
Rollout behavior
During rollout, the legacy role check decides the request outcome. The permission check runs beside the legacy check and records its decision. Mismatches between the two decisions are flagged for review. When RBAC Enforcement is on for a vendor, the permission check decides the outcome for that vendor. A failure inside the authorization system never blocks a request while the legacy check decides. A failure inside the authorization system denies the request when the permission check decides.
Principal sources
Every policy names the principal source its endpoint authenticates. The principal source determines whether the engine can evaluate a permission decision today.
- User. The default source. Session cookies and internal API keys both resolve to a user row through the session layer. Human organization users and API-key service users arrive as the same principal type. The engine evaluates user principals.
- Service. OAuth bearer tokens from the OnRamp auth server authenticate a service without a user identity. The permission system defers service-account principals. An endpoint with a service principal declares the source, and the legacy check decides until service-account grants exist.
- External user. Portal endpoints authenticate customer users. Customer users hold no vendor seat license. The permission system defers grants to external users. A portal endpoint declares the source, and the legacy check decides until external-user grants exist.
- None. Public endpoints and token-triggered background endpoints have no principal, so no permission decision exists. These endpoints use the public and non-interactive boundary declarations.
Pattern catalog
Single check before the operation
Global check. The permission is tenant-wide. The request payload does not affect the decision. Example: exporting data requires data.export.read at the global scope.
Resource check. The endpoint names the resource type and resource id from the request. The engine resolves the resource to its workspace, walking the parent chain when the resource cannot be granted to directly (subtask to task, task to project, project to workspace). The engine evaluates instance-level grants and workspace-level grants in one decision. The endpoint never passes a workspace id for an existing resource. Example: updating a project requires projects.project.update at that project's scope; the caller supplies only the project id.
Creation check. The object does not exist yet, so the engine cannot resolve a scope for it. The endpoint names the target workspace, taken from the request payload or from the default workspace. The engine checks the create permission at that workspace.
List filtering
Pre-fetch filter. The endpoint asks the engine for the scopes where the caller holds a permission. The endpoint constrains its database query to those scopes. Unauthorized rows never enter the result set, so page sizes and total counts stay correct. Use the pre-fetch filter for every paginated endpoint and every unbounded result set.
Post-fetch filter. The endpoint fetches a bounded result set first. The endpoint sends the candidate objects to the engine in one batch call. The engine returns the allowed subset. Use the post-fetch filter only when the result set is already small and bounded. Never use the post-fetch filter on a paginated endpoint: filtering after pagination corrupts page sizes and total counts.
Compound checks
Compound check. One atomic operation requires several permissions, possibly at several scopes. The endpoint checks every required permission before any work starts. Example: moving a project between workspaces requires the write permission on the source workspace and the write permission on the destination workspace.
Payload reference check. A mutation payload references secondary objects by id: an assignee, a template, an attached resource, a linked data field. Every object id accepted from the client receives its own check at that object's scope. The check on the operation's primary object does not cover the referenced objects. Unchecked payload references are the most common insecure-direct-object-reference defect in the application.
Batch check. One request operates on many instances. The engine checks each instance. The policy declares one of two failure behaviors. All-or-nothing rejects the whole request when any instance is denied. Partial skips the denied instances and reports them in the response.
Boundary declarations
Public. The endpoint requires no authentication. Examples: login, health checks, the application config blob. The declaration is explicit so the coverage check can tell "reviewed and public" from "forgotten".
Authenticated only. Any signed-in principal may call the endpoint. No permission check runs. The declaration is explicit for the same reason the public declaration is explicit.
Non-interactive execution. Scheduled jobs, webhooks, and background executions run without an interactive session. The operation is authorized at intake, under the identity of the triggering principal. The declaration names that triggering principal source. Undeclared background paths are treated as coverage gaps.
Service caller. The endpoint authenticates a service through an OAuth bearer token from the OnRamp auth server. The service holds no user identity, and the permission system defers service-account principals. The declaration records the service boundary. The legacy check decides until service-account grants exist.
External user (portal). The endpoint serves portal customer users. The permission system defers grants to external users. The declaration records the external-user boundary. The legacy check decides until external-user grants exist.
Ownership rules (excluded). A rule like "a user may edit their own comment" depends on the relationship between the caller and the object. The permission system does not express relationship rules. Relationship rules remain service-layer business rules and run in addition to the declared policy. The exclusion is recorded here so nobody encodes a relationship rule as a scope.
Choosing a pattern
| Operation shape | Pattern |
|---|---|
| Tenant-wide capability; payload irrelevant | Global check |
| Read or mutate one existing object | Resource check |
| Create a new object | Creation check |
| List objects; paginated or unbounded | Pre-fetch filter |
| List objects; small bounded set | Post-fetch filter |
| Atomic operation needing several permissions | Compound check |
| Mutation payload carries object ids | Payload reference check, added to the primary pattern |
| Bulk operation on many instances | Batch check |
| No authentication needed | Public |
| Any signed-in principal | Authenticated only |
| Job, webhook, or schedule trigger | Non-interactive execution |
| Service authenticates with an OAuth bearer token | Service caller |
| Portal endpoint serving customer users | External user (portal) |
Rules
- Every endpoint carries exactly one declared policy. A policy composes patterns when the operation needs more than one; a resource check plus a payload reference check is the common composition.
- Every paginated endpoint uses the pre-fetch filter.
- Endpoints reference permissions through the module-level permission constants, never through string literals.
- The engine owns containment resolution. An endpoint that looks up an object's workspace to pass it to a check is a defect.
- The coverage check walks the full route map. Every route matches a declared policy or appears in the shrink-only legacy baseline. The legacy baseline only shrinks; additions require a recorded justification.
Appendix: application survey, 2026-07-22
The numbers in this appendix are a point-in-time snapshot from 2026-07-22. An AST scan of the Python source under app/ (tests excluded) produced them; no application boot was involved. Re-run the scan before relying on the counts.
Authentication mechanisms
| Mechanism | Sites | Resulting principal |
|---|---|---|
Session cookie via login_required (flask-login) | 443 decorator-routed views, plus wrap and route-list sites | current_user: human organization users and portal customer users |
Internal API: shared secret header plus bearer API key (internal_api_auth_required) | 52 | Calls login_user(api_user), so the request converges on current_user with the API_USER role |
Either session or internal API key (internal_api_auth_or_session) | 1 route list | Same convergence on current_user |
OAuth bearer JWT from the OnRamp auth server (requires_session_or_oauth_jwt, agent internal auth) | authz and agent surfaces | Service principal; carries no user identity when the caller is a service |
EasyCron token (requires_easy_cron_token) | 4 | No principal |
| No authentication (portal PIN and SSO flow, webhooks, health checks, the auth server's own OAuth endpoints) | ~50 | No principal |
require_resource_permission (resources domain) | 9 route-list sites | Not authentication: a per-object share-permission check from the object-access system, which runs in parallel to RBAC and needs eventual reconciliation |
Route registration styles
| Style | Count | Notes |
|---|---|---|
@blueprint.route decorator stacks | 550 view functions | The most common style |
Manual decorator wrapping plus add_url_rule | 240 call sites | Wraps controller methods by explicit function application; one view function sometimes registers under several rules |
RouteDTO and LazyRouteDTO route lists | 199 instances | Declarative dataclasses with require_login, allowed_roles, and decorators fields; one registrar function applies every guard |
Class-based views (as_view) | 0 |
Two application-level before_request hooks exist: memory profiling and tenant-scope resolution for row-level security. The tenant-scope hook is the precedent for request-scoped security context.
Authorization decorator census
Counts cover the 550 decorator-routed views. The wrap and route-list surfaces apply the same guards through function application and registrar fields.
| Decorator combination | Views |
|---|---|
login_required only | 287 |
login_required + requires_access_level | 146 |
internal_api_auth_required | 37 |
internal_api_auth_required + max_json_body_size | 15 |
login_required + log_project_action | 5 |
requires_easy_cron_token | 4 |
login_required + requires_access_level + requires_vendor_feature | 3 |
requires_access_level without login_required | 1 |
| Other combinations | 2 |
| No auth-related decorator | 50 |
The 50 views without an auth-related decorator concentrate in the portal login flow (customer), health and base routes (base), scheduled-job routes (daily_jobs), and OAuth or webhook surfaces (auth_server, embed_widget, adobe, docusign). Each of the 50 needs an explicit boundary declaration (public, non-interactive, service caller, or external user) or an investigation during adoption. The single requires_access_level view without login_required still rejects anonymous callers, because requires_access_level checks authentication itself; the inconsistency is style, not exposure.