# Scope-aware business authorization (Phase 1B.5)

The access engine combines a persisted active user, a requested native Django
permission, and containment of a target by the SAME active role/organizational
assignment path. It defaults to denial. Role codes, usernames, primary assignments,
staff flags and UI visibility do not confer access.

## WHO / WHAT / WHERE

- **WHO:** the authenticated, persisted, active User.
- **WHAT:** an active Role containing the requested native Django Permission.
- **WHERE:** the canonical active UserOrganizationAssignment attached to that same
  active UserRoleAssignment.

The evaluator never takes WHAT from one path and WHERE from another.

## Public API

```python
from apps.access.authorization import (
    is_authorized, require_permission, authorized_queryset, scope_contains,
)

allowed = is_authorized(user=request.user,
                        permission="organization.view_servicecenter", target=center)
require_permission(user=request.user,
                   permission="organization.change_servicecenter", target=center)
visible = authorized_queryset(user=request.user,
                              permission="organization.view_servicecenter",
                              queryset=ServiceCenter.objects.filter(company=company))
```

require_permission is the minimal server-side view/service integration and raises
Django PermissionDenied with a generic message. Call it before protected work; hiding
a button is not enforcement. Existing Django Admin and health views are unchanged.
No future application pages, template tags, auth backend or audit logging are added.

scope_contains(assignment=assignment, target=target) checks active geometric/functional
containment only. It does NOT establish that anyone has a requested permission.
The earlier business_permissions_for_user remains an unscoped inventory and is also
NOT an authorization decision. Possessing a permission somewhere does not authorize
its use elsewhere.

## Inputs, identity and superusers

Permissions must be canonical app_label.codename strings, with identifier characters
and exactly one dot. They are not trimmed or inferred. Unknown, malformed or ambiguous
identifiers (same app/codename on multiple content types) deny for everyone, including
superusers. Permission type and scope type are independent: for example
organization.view_servicecenter against Company asks about that capability at company
scope. Callers must request the appropriate capability for their operation.

Supported targets are saved Company, Region, ServiceCenter, Department and
UserOrganizationAssignment instances. Unsupported models/proxies, unsaved/deleted
targets, and missing/anonymous/unsaved users deny. Identity comes from the authenticated
Django User supplied by the caller; views must use request.user, not a user chosen
from request parameters. Persisted database state is authoritative: modifying cached
is_superuser, is_active or target relation attributes cannot grant access. A lazy
Django request.user is supported. Only the configured default database is supported.

An authenticated, persisted, active superuser bypasses role and scope checks for all
existing supported targets, including inactive records for administrative lifecycle
work. The requested permission must still exist unambiguously. Inactive superusers
deny. Staff alone grants nothing. Native Groups and direct user_permissions do not
create a business authorization path; Django user.has_perm() and admin semantics
remain unchanged.

## Scope containment

All normal paths require the assignment's company to match the target's company.
Another company needs its own valid permission-and-scope path. All active paths are
considered; is_primary has no authorization meaning. Multiple roles are ORed only
within their own valid path, never by combining one path's permission with another
path's scope.

| Source assignment | Company target | Region target | Center target | Department target |
| --- | --- | --- | --- | --- |
| Company only | Same company | Its regions | Its centers | Its departments |
| Region only | Deny | Exact region | Centers in that region | Deny |
| Center (with region) | Deny | Deny | Exact center | Deny |
| Department only | Deny | Deny | Deny | Exact department |
| Center + Department | Deny | Deny | Exact center | Exact department |
| Region + Department | Deny | Exact region | Deny | Exact department |

Combined scope projections above apply only to the Organization entities themselves.
They do not grant department-independent capabilities on future domain records.
Region+Department is deliberately conservative: no center has a department relation
to prove that combined descendant containment, so centers deny. For the explicitly
supported Center+Department case, the exact center and exact department are allowed;
siblings and parent company/region are not.

For a UserOrganizationAssignment TARGET, every non-null source dimension must match
the corresponding target dimension. Source nulls leave that dimension unconstrained.
Thus a region scope may contain a center assignment within that region; a department
scope may contain an assignment with that department and an explicit geographic scope.
A Center+Department source cannot contain a center-only or department-only assignment.
Company-only scope contains valid assignment scopes in that company. The target may
belong to another user, enabling explicit organization administration; permission and
source-path ownership still belong to the acting user.

## Active state and consistency defenses

Normal decisions require active User, Role, UserRoleAssignment and
UserOrganizationAssignment. The role assignment's user must match the organizational
assignment's user. Every referenced source company/region/center/department must be
active, with matching company ownership and an explicit matching region for centers.
The engine checks these even if unsupported raw writes bypassed lifecycle validation.

Normal targets must also be active under an active, consistent hierarchy. Assignment
targets require active owners and valid active units. Inactive targets deny to ordinary
users, even if the user has a broad company scope; active superusers can administer
inactive records. Source checks include every dimension even when evaluating an exact
projection, so an inactive department invalidates a Center+Department source path.

No business role, permission or scope is inferred from names. No object is granted
because a different user's role assignment matches it. The existing inventory keeps
its separate Phase 1B.4 behavior.

## SQL filtering and query counts

scopes.py contains an explicit ScopeAdapter map, pairing each supported model's target
validity predicate with a correlated source-containment predicate. authorization.py
uses these SAME adapters for point checks and queryset filtering. SQL Exists keeps
permission membership and scope on a single source path and avoids duplicates from
multiple granting roles. Authorization does not materialize all targets or build a
Python list of all allowed IDs.

The decision SQL also checks current user/superuser state and permission existence,
so path state is evaluated within one PostgreSQL statement snapshot. With an already
resolved request user, a point decision, containment check, or evaluated authorized
queryset takes one database query. Building a valid authorized queryset is lazy and
performs zero queries. Tests verify these bounds, multiple-role deduplication and
agreement between point checks and queryset results.

Input filters, ordering and supported queryset projections remain in the result;
superusers receive the original filtered queryset. Unsupported model querysets,
pre-sliced querysets, combined union/intersection/difference queries and non-default
database queries yield an empty queryset. Filter before slicing or combining. Passing
something other than a QuerySet is a programming error (TypeError). Ordinary denial,
unknown permissions and unsupported targets do not expose internal exception details.
Database outages or malformed ORM construction are not silently converted to success.

No permission cache, Redis or authorization-time row locks are introduced. Revocation
is visible on a fresh decision/evaluation. As with any Django queryset, do not reuse
an already evaluated result as a long-lived authorization cache. A successful check
is not a transactional guarantee for a later write: future domain services must
coordinate authorization and mutations with appropriate transactions/locks to address
check-then-write races. No stronger concurrent-revocation guarantee is claimed.

## Extension boundary

A future domain can add an explicitly reviewed adapter with target validity and
containment predicates that express all relevant dimensions. There is no fallback
for unknown models and no runtime plugin discovery. Domain objects carrying both
center and department must enforce their intersection; do not copy the organization
entity projection rules as a shortcut. The current adapters are intentionally limited
to existing Organization models, with no speculative business models or resolvers.

The boolean/strict/filter API boundary is the place for future request integration
or deliberate audit hooks. This phase does not log decisions, change native Django
authentication, or convert existing admin screens to business-role authorization.

## Verification

No schema change or migration is needed. Existing models, migrations, lifecycle code,
permission inventory and prior tests are preserved.

```powershell
python manage.py check
python manage.py makemigrations --check
python manage.py migrate
python manage.py test apps.access.test_authorization --noinput
python manage.py test --noinput
```

The authorization test module has 34 tests, including a six-scope matrix across local
and foreign organizational targets, queryset parity, same-path privilege-escalation
regression, inactive/corrupted data, assignment intersections, superusers, native
permission isolation, malformed/ambiguous identifiers and query-count assertions.

The completion audit adds 9 security tests in test_authorization_audit.py covering
reverse and comparable path mixing, cross-company mixing in both directions,
inactive Role/role-assignment/organization-assignment mixing, multi-model queryset
isolation, and separate staff/direct/group checks. All original tests are retained.
No schema migration was generated.

Completion verification: 217 total tests passed, 0 failed (174 pre-1B.5 tests,
34 initial authorization tests and 9 completion-audit tests). All existing
PostgreSQL concurrency tests ran as part of the full suite.
