-
Notifications
You must be signed in to change notification settings - Fork 1.8k
ENG-10376 docs: add enterprise auth audit hook page #6818
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
FarhanAliRaza
wants to merge
1
commit into
reflex-dev:main
Choose a base branch
from
FarhanAliRaza:farhan/eng-10376-audit-hook-docs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+191
−1
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| --- | ||
| title: Auditing Auth Actions | ||
| --- | ||
|
|
||
| _New in reflex-enterprise v0.9.3._ | ||
|
|
||
| # Auditing Auth Actions | ||
|
|
||
| `rxe.AuthPlugin(audit=...)` registers a single observe-only hook that is called | ||
| for every auth lifecycle action (login, logout, token refresh, session expiry) | ||
| and every access decision the plugin makes on your behalf (the per-event gate | ||
| and the page guard). Use it to feed a SIEM, an audit table, or a structured | ||
| log — anywhere "who did what, and was it allowed" needs to be recorded. | ||
|
|
||
| ```python | ||
| # rxconfig.py | ||
| import reflex_enterprise as rxe | ||
|
|
||
| config = rxe.Config( | ||
| app_name="my_app", | ||
| plugins=[ | ||
| rxe.AuthPlugin( | ||
| audit="my_app.audit.audit_auth", | ||
| ), | ||
| ], | ||
| ) | ||
| ``` | ||
|
|
||
| ```python | ||
| # my_app/audit.py | ||
| import logging | ||
|
|
||
| audit_logger = logging.getLogger("my_app.audit") | ||
|
|
||
|
|
||
| async def audit_auth(action, outcome, context) -> None: | ||
| claims = context.userinfo or {} | ||
| audit_logger.info( | ||
| "%s %s provider=%s route=%s reason=%s user=%s", | ||
| action.value, | ||
| outcome.value, | ||
| context.provider, | ||
| context.route, | ||
| context.reason, | ||
| claims.get("sub"), | ||
| ) | ||
| ``` | ||
|
|
||
| Like `auth_providers` and the page builders, `audit=` accepts either the | ||
| callable itself or a `"module.function"` import-path string. Use the string | ||
| form in `rxconfig.py` — app modules cannot be imported while the config is | ||
| still being assigned. The path is resolved at compile time, so a typo fails | ||
| startup with a clear error rather than surfacing at the first audited event. | ||
|
|
||
| ## The hook contract | ||
|
|
||
| The hook is called as `audit(action, outcome, context)`: | ||
|
|
||
| - **Sync or async** — both are accepted; an awaitable result is awaited. | ||
| - **Observe-only** — the return value is ignored. The hook can never veto or | ||
| alter a decision; authorization semantics stay in the `auth=` checks. | ||
| - **Fail-open** — a raising hook is logged at `ERROR` (with traceback) and the | ||
| auth flow proceeds unaffected. | ||
| - **Awaited inline** — events for a session arrive in causal order, but the | ||
| hook runs on the hot path. Keep it fast; heavy sinks (HTTP, database) should | ||
| enqueue internally and flush out-of-band, projecting the context down to the | ||
| plain fields they need first. | ||
|
|
||
| `AuditAction` and `AuditOutcome` are str-valued enums and `AuditContext` is a | ||
| flat frozen dataclass, so events serialize into log/JSON sinks without | ||
| importing framework internals. Always read `.value` when formatting: on | ||
| Python 3.11+ `f"{action}"` renders `AuditAction.LOGIN_COMPLETED` while 3.10 | ||
| renders the bare value. | ||
|
|
||
| ## Actions and outcomes | ||
|
|
||
| Lifecycle actions record the user interacting with the plugin: | ||
|
|
||
| | Action | Emitted when | Outcomes | | ||
| | --- | --- | --- | | ||
| | `login_started` | The authorization redirect to the IdP is issued. | `success` / `failure` | | ||
| | `login_completed` | The callback token exchange finishes (or fails). | `success` / `failure` | | ||
| | `logout` | The session is cleared — user logout, popup sync, or stale-identity cleanup. | `success` / `failure` | | ||
| | `token_refresh` | An access-token refresh exchange completes (or is rejected). | `success` / `failure` | | ||
| | `session_expired` | A session dies without a logout: invalid tokens, rejected userinfo, or cookies that expired out-of-band. | `success` | | ||
|
|
||
| Access decisions record every allow/deny the plugin makes, phrased as what the | ||
| user experienced: | ||
|
|
||
| | Action | Surface | Outcomes | | ||
| | --- | --- | --- | | ||
| | `event_handler` | The per-event gate on protected event handlers. | `allowed` / `redirected_to_login` / `denied_toast` | | ||
| | `page_load` | The page guard on protected pages. | `allowed` / `redirected_to_login` / `redirected_to_forbidden` | | ||
|
|
||
| Public surfaces are not audited: an explicit `auth=False` handler, a public | ||
| page under an `auth=False` default, and framework events like `hydrate` emit | ||
| nothing — auditing "everything is public and allowed" would flood the sink. | ||
| Field and computed-var withholding (delta filtering and redelivery) is also | ||
| not audited; those derive from the same identity the gate and guard decisions | ||
| already recorded. | ||
|
|
||
| On failures, `context.reason` carries a machine-readable cause — for example | ||
| `"csrf_state_mismatch"`, `"token_exchange_failed"`, `"refresh_failed"`, | ||
| `"tokens_invalid"`, or `"stale_cookies"` — and `context.error_txid` matches | ||
| the error transaction id in the provider's backend logs and error UI. | ||
|
|
||
| ## The context | ||
|
|
||
| `AuditContext` is frozen and keyword-only; new optional fields may be added | ||
| over time, but the 3-argument hook signature never changes. | ||
|
|
||
| | Field | Meaning | | ||
| | --- | --- | | ||
| | `state` | The state instance the audited flow ran on — always present, and the door into app state via `await context.state.get_state(...)`. | | ||
| | `auth_user_state` | The live per-token `AuthUserState` handle, or `None` if it could not be loaded. | | ||
| | `userinfo` | A claims snapshot at emission time. Emissions adjacent to a session reset (logout, expiry) capture it *before* the reset, so the event still records who logged out. | | ||
| | `provider` | The involved provider's name, when known. | | ||
| | `route` | The current page URL, when available. | | ||
| | `session_id` | The router session id, when available. | | ||
| | `handler_name` | The gated handler's function name (`event_handler` action only). | | ||
| | `event_handler` | The gated `EventHandler` object (`event_handler` action only). | | ||
| | `payload` | The gated event's payload, verbatim (`event_handler` action only). | | ||
| | `reason` | Machine-readable cause, e.g. `"refresh_failed"`. | | ||
| | `error_txid` | Correlates with the provider's backend error logs. | | ||
| | `timestamp` | `time.time()` at emission. | | ||
|
|
||
| ```md alert warning | ||
| # Redaction is the hook's responsibility | ||
| `payload` carries the gated event's arguments verbatim (form input, possibly | ||
| secrets) and `userinfo` carries the full IdP claims (PII). Project to the | ||
| fields your sink needs — log `sub`, not the whole claims dict. | ||
| ``` | ||
|
|
||
| ## Writing audit events into app state | ||
|
|
||
| `context.state` works exactly like `self` in an event handler: reach any | ||
| state via `get_state`. This trail state is deliberately public (`auth=False`) | ||
| so denial events recorded while anonymous still render after the redirect: | ||
|
|
||
| ```python | ||
| import reflex as rx | ||
| import reflex_enterprise as rxe | ||
|
|
||
|
|
||
| class AuditTrailState(rx.State): | ||
| entries: rx.Field[list[dict[str, str]]] = rxe.field([], auth=False) | ||
|
|
||
|
|
||
| async def audit_auth(action, outcome, context) -> None: | ||
| trail = await context.state.get_state(AuditTrailState) | ||
| trail.entries = [ | ||
| *trail.entries[-29:], | ||
| {"action": action.value, "outcome": outcome.value}, | ||
| ] | ||
| ``` | ||
|
|
||
| Entries ride along with the next state delta, so a decision made while a | ||
| redirect is in flight (a gate-blocked event, for example) shows up once the | ||
| visitor lands back on a page. | ||
|
|
||
| ## Volume | ||
|
|
||
| `event_handler`/`allowed` is the highest-frequency emission: it fires once per | ||
| protected handler per event, so a busy app records a lot of "allowed". Hooks | ||
| that only care about denials or lifecycle events should filter on | ||
| action/outcome first, before doing any I/O. | ||
|
|
||
| ## Popup flows | ||
|
|
||
| Popup login/logout emits for both windows' sessions: the popup window's own | ||
| session records the provider round-trip (`login_started`, `login_completed`), | ||
| and the opener's session records the token handoff — `login_completed` with | ||
| reason `"popup_tokens_synced"`, `logout` with reason `"popup_logout_synced"`. | ||
| Hooks counting unique logins should key on one side of the pair; identity | ||
| (`userinfo`) is most complete on the opener's events. | ||
|
|
||
| See the [overview](/docs/enterprise/auth/overview/) for how the plugin fits | ||
| together, and [secure by default](/docs/enterprise/auth/secure-by-default/) | ||
| for the `auth=` checks whose decisions these events record. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a hook that passes the event directly to a JSON/SIEM encoder, this claim is incorrect: frozen dataclasses are not JSON-serializable by default, and this context also contains live
State,AuthUserState, andEventHandlerobjects plus an arbitrary payload. Standard encoders will raiseTypeError, contradicting a central advertised use case. Clarify that callers must explicitly project the context to JSON-safe fields, as the preceding paragraph recommends, rather than implying the event itself can be serialized.Useful? React with 👍 / 👎.