Metadata-Version: 2.4
Name: grad-pylib
Version: 3.0.0
Summary: Internal core helpers and wrappers for FastAPI backends
Project-URL: Source, https://github.com/graduatecollege/grad-pylib
Author-email: Mikael Arvola <marvel@illinois.edu>
License-File: LICENSE
License-File: NOTICE
Requires-Python: <3.15,>=3.14
Requires-Dist: fastapi-azure-auth<5.4,>=5.2
Requires-Dist: fastapi<0.142,>=0.138
Requires-Dist: httptools>=0.8.0
Requires-Dist: pydantic-settings~=2.14.1
Requires-Dist: pyodbc>=5.3.0
Requires-Dist: sqlalchemy~=2.0.50
Requires-Dist: structlog>=26.1.0
Requires-Dist: tenacity>=9.1.4
Requires-Dist: uvicorn[standard]<0.53,>=0.49
Description-Content-Type: text/markdown

# grad_pylib

Graduate College Python common library for web application APIs and other related projects.

Additional docs in the repository root:

* [AUTH.md](AUTH.md)
* [DB.md](DB.md)
* [E2E.md](E2E.md)
* [PARAMS.md](PARAMS.md)
* [QUERYING.md](QUERYING.md)
* [RESPONSES.md](RESPONSES.md)
* [SCHEMA.md](SCHEMA.md)

## Authentication

Applications authenticate with Azure AD through `grad_pylib.core.auth`. A few things are worth
knowing before deploying a service that uses it.

### Set `ENVIRONMENT`

`ENVIRONMENT` must be set explicitly for every deployment (`production` for production). Only
`development`, `local` and `test` are treated as development environments. Outside those, the
`.env` file is not loaded at all, so a stray dotenv file in the working directory cannot override
production configuration.

### The development API key is a full bypass

When `ENABLE_DEV_API_KEY` is turned on, a request carrying the `Api-Key` header authenticates as
any role it asks for through the `Api-Role` header, with no token involved. This is intentional:
arbitrary impersonation is needed for local development and automated end-to-end tests. It is
guarded as follows:

* `ENABLE_DEV_API_KEY` must be explicitly enabled, and settings validation refuses it outside a
  development environment.
* The request must arrive from a loopback address. Forwarding headers such as `X-Forwarded-For`
  are ignored, so a leaked key is not remotely exploitable.
* A request that presents `Api-Key` when the bypass is unavailable, or presents the wrong key, is
  rejected with a 401 and audited. It never falls through to Azure AD authentication.

Validating the requested role against the application's roles is the consuming project's
responsibility, inside its `api_key_user_builder`.

### Audit logging

Every authentication decision is emitted to the `grad_pylib.audit.auth` logger as a structured
event: `auth.access.granted`, `auth.access.denied`, `auth.failed`, `auth.token.rejected`,
`auth.api_key.bypass` and `auth.roles.overridden`. Records include the subject, policy,
mechanism, effective roles, client address and request path. Note that `override_loader` is a
privilege-granting hook, and any override that changes the effective roles is logged.

### Identities

Only `illinois.edu` and `uillinois.edu` UPNs are accepted; other domains and tokens without a UPN
claim are rejected with a 401. Only the application's own user object is stored on
`request.state.user`, so the raw access token is not left where an error handler or APM
integration could serialize it.

The library's contract for a user is the `AuthUser` protocol — a read-only `effective_roles`
sequence. An application can satisfy it with any immutable type it likes; `BaseUser` is a
ready-made implementation, not a required base class. Its fields are stored exactly as declared
(tuples, and a read-only attribute mapping), so untrusted values are normalized where they enter
the application, with `parse_roles()` and `parse_distinct_strings()`. Users are immutable — use
`with_roles_override()` or `dataclasses.replace()` to derive a modified user.

### Authorization configuration

`AuthConfiguration` is a frozen pydantic model that is validated once, at startup:

* `policy_roles` entries must name roles from `valid_roles`. They are matched case-insensitively
  and stored in the canonical casing, so a typo or a casing mismatch is a startup error rather
  than an endpoint that silently never grants access.
* A policy with no roles is rejected at construction rather than when its dependency is built.
* The policy mapping and its role sets are deep-frozen, so a caller holding a reference to what
  it passed in cannot change authorization decisions at runtime.

A policy grants access when the user holds *any* of its roles.
