Metadata-Version: 2.5
Name: easybiz-keycloak-auth
Version: 2.0.0
Summary: Shared Keycloak JWT authentication and role-based API permission for EasyBiz Django services.
Project-URL: Homepage, https://github.com/EasyBizIO/easybiz-keycloak-auth
Project-URL: Repository, https://github.com/EasyBizIO/easybiz-keycloak-auth
Author: EasyBiz
License: Proprietary
License-File: LICENSE
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Requires-Python: >=3.11
Requires-Dist: django>=4.2
Requires-Dist: djangorestframework>=3.15
Requires-Dist: pyjwt[crypto]>=2.9
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == 'test'
Description-Content-Type: text/markdown

# easybiz-keycloak-auth

Shared Keycloak JWT authentication and role-based API permission, used by
every EasyBiz Django service so the same logic isn't hand-copied into 10+
repos.

```python
from easybiz_keycloak_auth.authentication import KeycloakAuthentication, KeycloakUser
from easybiz_keycloak_auth.permissions import KeycloakApiRolePermission

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "easybiz_keycloak_auth.authentication.KeycloakAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
        "easybiz_keycloak_auth.permissions.KeycloakApiRolePermission",
    ],
}
```

`KeycloakAuthentication` validates an RS256 Bearer token against the realm's
JWKS endpoint and attaches a `KeycloakUser` (claims dict, not a Django `User`)
to the request. `KeycloakApiRolePermission` then gates read/write access by
role claim — see below.

## What this package is *not*

It does not cover the Django-admin "log in via Keycloak" OAuth flow (that
touches each service's own local `User` model and stays service-side) or
Keycloak-admin-API user provisioning. Only the stateless JWT-validation and
role-gate pieces, which are identical across services, live here.

## Settings contract

Every setting below is read from `django.conf.settings` by attribute name —
no Django app needs registering, just define what you use.

| Setting | Required | Default | Purpose |
|---|---|---|---|
| `KEYCLOAK_URL` | yes | — | Base URL of the Keycloak server. |
| `KEYCLOAK_REALM` | yes | — | Realm name; JWKS fetched from `{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs`. |
| `KEYCLOAK_API_CLIENT_ID` | no | none (empty role set) | This service's own Keycloak client id — `client_roles()` reads `resource_access.<this>.roles`. |
| `KEYCLOAK_LEGACY_CLIENT_ID` | no | `""` (bypass disabled) | If a token's `azp` claim equals this, `KeycloakApiRolePermission` grants full access — for a legacy shared client that predates per-service roles. |
| `OIDC_ADMIN_ROLE` | no | `""` (bypass disabled) | Legacy realm role that grants full access. |
| `KEYCLOAK_API_ADMIN_ROLE` | no | `""` (bypass disabled) | Client role (under `KEYCLOAK_API_CLIENT_ID`) that grants full access. |
| `KEYCLOAK_DJANGO_ADMIN_ROLE` | no | `""` (bypass disabled) | Client role (under `KEYCLOAK_API_CLIENT_ID`) that grants full access — same standalone `django-admin` role that opens `/admin`, per the org role catalog it bypasses API permissions too, not just the admin site. |
| `KEYCLOAK_EDITOR_ROLE` | no | `"editor"` | Client role granting read+write. |
| `KEYCLOAK_VIEWER_ROLE` | no | `"viewer"` | Client role granting read only. |
| `KEYCLOAK_SERVICE_WRITE_ROLE` | no | `"service-write"` | Service-to-service role granting read+write. |
| `KEYCLOAK_SERVICE_READ_ROLE` | no | `"service-read"` | Service-to-service role granting read only. |

`KeycloakApiRolePermission` also always bypasses for Django session principals
where `request.user.is_staff` or `.is_superuser` is true (e.g. the admin
site), independent of any Keycloak setting.

## Relationship to the org-wide permissions plan

`easybiz-system-docs/new-platform/permissions` describes a target three-layer
model: Keycloak (who, which apps), OpenFGA (which records), and a planned
`easybiz-auth` package that wraps both. This package only covers the Keycloak
layer — JWT validation and the ladder/service-role permission gate — and is
deliberately narrower than that plan's `easybiz-auth`. It is not yet renamed
or merged into that name; do that only once OpenFGA-backed object-level
checks are actually being built, not preemptively.

The target catalog defines **eight** client roles per app: `viewer`,
`editor`, `admin` (composite ladder, resolved by Keycloak itself — a token
with `admin` already carries `editor`/`viewer` too), `django-admin`,
`client`, `service-read`, `service-write`, `act-on-behalf`. This package's
`KeycloakApiRolePermission` only grants access based on
`editor`/`viewer`/`service-write`/`service-read` (plus the `django-admin`/
staff/superuser bypass and the optional legacy-client/admin-role bypasses
above). `client` (portal) and `act-on-behalf` may exist as real Keycloak
roles but are **intentionally not** wired into this permission class:

- `client` is meant to grant scoped access to a user's *own* record only
  (via OpenFGA), not blanket collection access through `/api/v1/` — adding
  it to the read-role set here would be a privilege escalation, not a
  no-op. It belongs behind a separate portal-facing view/permission class
  once built.
- `act-on-behalf` is meant to be checked by a future acting-subject
  middleware alongside an OpenFGA check on the acted-upon user, not as a
  blanket grant either.

**Extension point for later:** `KeycloakApiRolePermission.has_permission`
answers "may this token call this endpoint at all" and deliberately says
nothing about *which records*. Object-level filtering (OpenFGA or otherwise)
should layer on as a separate `has_object_permission` override or a
queryset-filtering mixin, without changing this class's existing contract —
so consumers doing plain role-gating today aren't affected when that lands.

## `roles.py` helpers

`realm_roles(claims)`, `client_roles(claims, client_id=None)`, and
`has_any_role(claims, *, realm_role=None, client_role=None)` — for building
custom role gates beyond the blanket `KeycloakApiRolePermission` (e.g. a
single curator-only endpoint). See `company_resolver/permissions.py` in
`easybiz-master-data-service` for an example.

## Versioning policy

| Bump | When | Consequence |
|---|---|---|
| **MAJOR** | Any change to *who* a given token is granted access to — adding/removing a bypass, changing which roles satisfy read/write, changing a default role name | Every consumer must review before upgrading; a stale pin is always safe, never silently more permissive. |
| **MINOR / PATCH** | New optional setting, docs, tests, non-behavioral refactor | Must **never** change an existing token's outcome. |

## Installation

- **Now (solo inner loop):** clone next to the service repos and install editable.
  ```
  pip install -e ../easybiz-keycloak-auth
  ```
- **When CI or a teammate appears — switch to a pinned git tag:**
  ```
  pip install git+https://github.com/<org>/easybiz-keycloak-auth@v2.0.0
  ```
- **Published:** public PyPI, same as `easybiz-companyname-normalisation` /
  `easybiz-companyname-shortcode`.
  ```
  pip install easybiz-keycloak-auth==2.0.0
  ```

Pin a **tag or exact version, never a branch** — a moving ref lets two
services drift onto different access-control behavior silently.
