Metadata-Version: 2.4
Name: superbuilders-authz
Version: 0.0.4
Summary: Track A (FERPA/COPPA) application-layer authorization resolver (PDP+PEP) — Python sibling of @superbuilders/authz for bridge's Python tier.
License: UNLICENSED
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"

# superbuilders-authz (Python)

> Part of the [`superbuilders/timeback-authz`](https://github.com/superbuilders/timeback-authz) monorepo (`python/`). It shares the cross-language conformance vectors in `../conformance/vectors.json` with the TS package — policy changes must update the vectors and pass both suites.

Track A (FERPA/COPPA) **application-layer authorization resolver** — the Python sibling of [`@super-oar/authz`](https://github.com/superbuilders/timeback-authz), built for `bridge`'s Python tier (TA-PEP-03 / TA-PEP-10, WS3 shadow-authz seam). Faithful port: **same gate order, same verdicts, same reasons, same shadow-log shape** — both languages' `[authz-shadow]` logs aggregate identically on the deny-storm dashboards.

- **Pure stdlib, no DB required, no native deps** → runs anywhere (Lambda, containers, cron workers).
- **Application-layer first.** `decide()` returns a decision **+ a filter spec** and works with **no database** — because most consumers read student data over REST / MySQL / DynamoDB, not a Postgres the package can attach to. RLS/GUC enforcement is a **later phase** and is intentionally **not** in this package.
- Decision core is **swappable** (ADR-11): SQL capability merge now → Cedar/OpenFGA later, same surface.
- Import package: `superbuilders_authz`. Python `>= 3.11` (bridge pins 3.12).

## API

- **`await decide(DecideInput(...), deps) → Decision(verdict, reason, filter?, mask_fields?)`** — the core. Ordered gates (tenant → role active+window → read-only → capability+scope → sensitivity → consent → elevation); first-fail-denies; `dual_read`/`shadow` downgrade to `would_*` (deploying denies nobody). Omit `resource` for a list read → returns a `filter` (allowed org/user/class ids) to constrain your query.
- **Shadow seam** (the WS3 deliverable): `shadow_compare(...)` runs `decide()` **beside** your existing check and logs the comparison. **Never raises, never changes the request outcome** (returns `None` on any error). `build_coarse_principal(...)` / `coarse_role_capabilities(role)` build an INTERIM principal from the coarse OneRoster role until WS5 seeds `role_capabilities` (fail-closed on unknown roles).
- **Guards** (raise `AuthzError` (403) in enforce, return `would_*` in shadow): `authorize`, `assert_parent_child_access`, `assert_teacher_class_access`, `assert_school_access`, **`assert_can_read`** (object-level BOLA), `require_admin` / `require_teacher_or_admin` / `require_parent_or_guardian`.
- **Capability merge**: `merge_capabilities` (`role_capabilities ⊕ user_capability_grants`, deny-precedence, NULL tier → most-restrictive, M2M `client_app` keys).
- **Consent**: `classify_consent_boundary`, `method_satisfies_basis` / `is_vpc` (COPPA under-13 VPC split), `evaluate_consent` / `mask_fields_for` (opt-out / Sec-GPC hard deny; special_pops + McKinney-Vento + directory-opt-out field masking).
- **Escalation**: `can_grant` — the TA-PEP-15 no-escalation invariant (roles.manage precondition, in-set at equal-or-lesser scope, dual-control on `compliance_gated`).

## You inject your store + identity (the per-app glue)

```python
from superbuilders_authz import DecideDeps, ScopeResult

async def resolve_scope(scope, subject) -> ScopeResult:
    ...  # your enrollments / guardianship_links / orgs query -> ScopeResult

async def consent_ok(child_sourced_id: str, purpose: str, tenant_id: str) -> bool:
    ...  # your consent_records lookup -> bool

deps = DecideDeps(resolve_scope=resolve_scope, consent_ok=consent_ok)
```

- Build `Principal` from your **already-verified** identity (Cognito JWT / Clerk / Supabase). **Never** trust an IdP group/claim/metadata for capabilities — resolve them from the authoritative store. Use `system=True` for non-HTTP workers (cron/sync), `read_only=True` for read-only service accounts.
- Postgres consumers can use the bundled adapter instead of hand-writing the glue: `create_scope_resolver(q)` / `create_consent_checker(q)` over any driver wrapped to satisfy the `AuthzQuery` protocol (asyncpg/psycopg/supabase).

## Shadow usage (WS3)

```python
from superbuilders_authz import build_coarse_principal, shadow_compare, ResourceRef, ScopeResult

subject = build_coarse_principal(user_sourced_id="T1", role="teacher", org_sourced_id="S1")
await shadow_compare(
    subject=subject,
    action="academics.read",
    resource=ResourceRef(kind="student", owner_sourced_id=student_id, tenant_id=subject.tenant_id),
    scope=ScopeResult(user_sourced_ids=my_already_resolved_student_ids),  # relationship truth the app already has
    current_allowed=existing_check_passed,  # what your current authorization decided
    label="lalilo.rostered_students",       # stable call-site label for the dashboards
)
```

Emits (identical to the TS package — same prefix, same field names, same order):

```
[authz-shadow] {"label":"lalilo.rostered_students","action":"academics.read","role":"teacher","owner":"stu1","current":"allow","would":"would_allow","agree":true,"reason":"ok","maskFields":[]}
```

## Test / build

```bash
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest   # capability merge + RC fail-closed regressions + shadow would_* downgrade + log-shape parity
```

## Not covered (mirrors the TS package)

RLS / GUC enforcement, the signed impersonation grant, break-glass MFA activation, and the biometric short-lived VPC capability token are app/infra-side deliverables, not in this package.
