Skip to content

[PP-3772] Add patron blocking rules per library#3090

Open
dbernstein wants to merge 10 commits intomainfrom
feature/PP-3772-Add-patron-blocking-rules-per-library
Open

[PP-3772] Add patron blocking rules per library#3090
dbernstein wants to merge 10 commits intomainfrom
feature/PP-3772-Add-patron-blocking-rules-per-library

Conversation

@dbernstein
Copy link
Contributor

@dbernstein dbernstein commented Feb 27, 2026

Description

Adds per-library patron blocking rules to the SIP2 authentication provider, with the rule model and evaluation hook placed at the BasicAuthenticationProvider level for future reuse across other providers. Implements a simpleeval-based rule engine for patron blocking rules and wires it into SIP2 validation and runtime authentication.

What this does

  • New model: PatronBlockingRule — a frozen Pydantic value object with name (required), rule (required), and message (optional). Stored in the existing per-library settings JSON blob; no migration needed.
  • patron_blocking_rules field on BasicAuthProviderLibrarySettings — all basic-auth providers inherit the field (hidden in the admin UI for now). Validation enforces non-empty name/rule and unique names within a library's rule list, with array-indexed error messages consistent with the existing ProblemDetail validation pattern.
  • check_patron_blocking_rules() function in a new protocol-agnostic module (palace/manager/integration/patron_auth/patron_blocking.py). v1 stub: any rule whose rule expression equals the literal string "BLOCK" returns a BLOCKED_CREDENTIALS ProblemDetail (HTTP 403); all other expressions are no-ops.
  • supports_patron_blocking_rules: ClassVar[bool] on BasicAuthenticationProvider — defaults to False. Providers set it to True to opt in to the blocking hook. Currently only SIP2AuthenticationProvider opts in; Millenium, Kansas, and SirsiDynix can be enabled in follow-up PRs.
  • BasicAuthenticationProvider.authenticate refactor — the original logic is extracted into _do_authenticate. The public authenticate wraps it: if supports_patron_blocking_rules is True and the ILS returns a Patron, blocking rules are evaluated before the patron is returned.

New module: rule_engine.py

src/palace/manager/api/authentication/patron_blocking_rules/rule_engine.py

  • Placeholder syntax: {key} placeholders are compiled to safe variable identifiers (__v_key) and resolved via simpleeval's names dict — no string interpolation.
  • Strict boolean: expressions must evaluate to a strict bool; any other type is an error.
  • Fail-closed runtime: evaluate_rule_expression_strict_bool raises RuleEvaluationError on missing placeholders, parse/eval errors, or non-bool results.
  • Validation: validate_rule_expression does a trial evaluation against a supplied test-values dict; rejects empty/too-long/invalid/non-bool expressions via RuleValidationError.
  • Allowed functions: locked-down EvalWithCompoundTypes with an explicit whitelist. First function: age_in_years(date_str, fmt=None, *, today=None) — ISO parse with dateutil fallback, injectable today for deterministic tests.
  • Exceptions: RuleValidationError, RuleEvaluationError (with rule_name), MissingPlaceholderError (with key).
  • Constants: MAX_RULE_LENGTH = 1000, MAX_MESSAGE_LENGTH = 1000.

Updated: patron_blocking.py

  • RULE_VALIDATION_TEST_VALUES — deterministic dict (fines, patron_type, dob) used only at admin-save validation time.
  • build_runtime_values_from_patron(patron) — builds the runtime values dict from a Patron DB object (fines as float, patron_type as str). dob is intentionally omitted for now; rules referencing it fail-closed.
  • check_patron_blocking_rules_with_evaluator(rules, values, log=None) — evaluates rules via simpleeval, fail-closed on any error. Creates a fresh evaluator per call (thread-safe). Logs internal errors server-side without leaking them to the patron.
  • Legacy check_patron_blocking_rules (literal-BLOCK sentinel) retained for its own unit tests.

Updated: basic.py

  • validate_patron_blocking_rules field validator — extended to call validate_rule_expression against RULE_VALIDATION_TEST_VALUES for each rule, and check len(rule.message) > 1000. Errors surface as SettingsValidationError with INVALID_CONFIGURATION_OPTION and an index-prefixed message, matching existing admin field-error patterns.
  • authenticate method — replaced the legacy literal-BLOCK check with build_runtime_values_from_patron + check_patron_blocking_rules_with_evaluator, passing self.log for server-side diagnostics.

Files changed

  • src/palace/manager/api/authentication/basic.py — Added patron_blocking_rules field + validator, supports_patron_blocking_rules flag, and authenticate / _do_authenticate refactor
  • src/palace/manager/integration/patron_auth/patron_blocking.py — New: PatronBlockingRule model and check_patron_blocking_rules()
  • src/palace/manager/integration/patron_auth/sip2/provider.py — Sets supports_patron_blocking_rules = True
  • tests/manager/integration/patron_auth/test_patron_blocking.py — New: 29 unit tests covering the model, validation, pure function, and the flag/hook behavior
  • tests/manager/integration/patron_auth/sip2/test_provider.py — SIP2 integration tests for the blocking hook through a real provider instance

Not in scope (v1)

  • Enabling blocking rules for Millenium, Kansas, or SirsiDynix

Motivation and Context

https://ebce-lyrasis.atlassian.net/browse/PP-3772

How Has This Been Tested?

New unit test coverage.
It was also manually tested locally with the front end changes (ThePalaceProject/circulation-admin#201)

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

❌ Patch coverage is 92.65306% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.22%. Comparing base (450a2d6) to head (98d9c5e).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
...e/manager/integration/patron_auth/sip2/provider.py 50.00% 13 Missing ⚠️
...uthentication/patron_blocking_rules/rule_engine.py 98.37% 1 Missing and 1 partial ⚠️
...manager/integration/patron_auth/patron_blocking.py 95.65% 2 Missing ⚠️
src/palace/manager/api/authentication/basic.py 96.96% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3090      +/-   ##
==========================================
- Coverage   93.22%   93.22%   -0.01%     
==========================================
  Files         491      493       +2     
  Lines       45364    45607     +243     
  Branches     6246     6275      +29     
==========================================
+ Hits        42292    42517     +225     
- Misses       1984     2001      +17     
- Partials     1088     1089       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein dbernstein force-pushed the feature/PP-3772-Add-patron-blocking-rules-per-library branch 2 times, most recently from fcbea27 to 3a302fd Compare March 3, 2026 20:20
@dbernstein dbernstein marked this pull request as ready for review March 3, 2026 20:22
@dbernstein dbernstein force-pushed the feature/PP-3772-Add-patron-blocking-rules-per-library branch 3 times, most recently from e227145 to 40c4ef5 Compare March 4, 2026 21:59
Extends SIP2LibrarySettings with an optional patron_blocking_rules field
(list of PatronBlockingRule objects). Each rule has a required name, a
required rule expression, and an optional patron-facing message.

Validation is integrated into the existing SettingsValidationError /
BaseSettings pattern: empty name, empty rule expression, and duplicate
rule names within a library all surface as INVALID_CONFIGURATION_OPTION
ProblemDetails with the array index in the message, consistent with how
all other LibrarySettings validation errors are reported.

The authentication hook stub overrides authenticate() in
SIP2AuthenticationProvider. After the base class authenticates a patron
successfully, check_patron_blocking_rules() is called. Any rule whose
rule expression equals the literal "BLOCK" returns a BLOCKED_CREDENTIALS
(HTTP 403) ProblemDetail, using the rule's message if provided. All other
expressions are pass-throughs (stub for a future rule engine).

Storage requires no migration: settings are already a JSON blob in
IntegrationLibraryConfiguration.settings_dict, and the new field
defaults to an empty list that is excluded from model_dump(), so
existing library rows continue to load without change.

No changes to PatronAuthServicesController or IntegrationSettingsController
are needed; the field round-trips through the existing libraries JSON
payload path automatically.

30 new tests cover: PatronBlockingRule model, SIP2LibrarySettings
round-trip and all validation failure cases, check_patron_blocking_rules
pure-function behaviour, and the authenticate() override (blocked,
unblocked, no rules, None pass-through, ProblemDetail pass-through).
Non-SIP2 providers are confirmed unaffected.
    MissingPlaceholderError now accepts an available dict and embeds all available keys + values in the message (e.g. "Available fields: fee_amount='$5.00', fines=5.0, ..."). The available attribute is also accessible for programmatic use.
* build_names now passes the full values mapping to MissingPlaceholderError.
* Both validate_rule_expression and evaluate_rule_expression_strict_bool now catch FunctionNotDefined separately and include the list of allowed functions in the error (e.g. "Allowed functions: age_in_years").
* Added _format_available_keys and _format_allowed_functions helpers. Imported FunctionNotDefined from simpleeval.
* patron_blocking.py — Removed RULE_VALIDATION_TEST_VALUES; build_values_from_sip2_info now returns all raw SIP2 fields verbatim plus the normalised fines float — the old explicit patron_type and dob mappings are gone since those fields appear naturally in the raw dict.
* basic.py — Pydantic static validator now only enforces structural constraints (empty name, empty rule, duplicate names, rule length ≤ 1000, message length ≤ 1000). The syntax/semantic check against live values is done exclusively in library_integration_validation.
* Tests — Updated test_rule_engine.py to assert available keys appear in missing-placeholder errors and allowed functions appear in function-not-found errors. Updated test_patron_blocking.py to remove TestRuleValidationTestValues, drop tests that relied on static Pydantic expression validation, and rewrite TestBuildValuesFromSip2Info to verify all raw fields pass through. Updated test_patron_auth.py to use {custom_field} instead of {dob} in the missing-placeholder test.
* check_patron_blocking_rules (the legacy "BLOCK" string sentinel) was deleted from patron_blocking.py along with its entire docblock.
* TestCheckPatronBlockingRules (and the check_patron_blocking_rules import) were removed from test_patron_blocking.py.
* Runtime auth flow wired up to the full SIP2 response
* basic.py — added the _build_blocking_rule_values(patron, credentials) hook method. The base implementation calls build_runtime_values_from_patron (DB-backed fallback for any future non-SIP2 provider). authenticate() was updated to use the hook and also short-circuits when patron_blocking_rules is empty (avoids calling _build_blocking_rule_values unnecessarily).
* sip2/provider.py — thread-local module variable _sip2_thread_local was added. remote_authenticate() now caches the raw SIP2 patron_information dict in _sip2_thread_local.last_info immediately after the network call, at zero extra cost. _build_blocking_rule_values() reads that cache and calls build_values_from_sip2_info(info) to expose every SIP2 field to rule expressions. If the cache is None (e.g. when _do_authenticate is patched in tests), it falls back to the patron-model path.
Tests
* TestSIP2AuthenticateWithBlockingRules received:
* An autouse fixture that zeroes _sip2_thread_local.last_info before and after every test, preventing cross-test pollution.
* Updated test_missing_placeholder_fails_closed — comment now correctly describes the patron-fallback path taken when remote_authenticate is bypassed.
* Four new tests that directly set _sip2_thread_local.last_info to simulate the real runtime SIP2 path:
* test_raw_sip2_field_blocks_when_rule_matches / …_allows_when_…
* test_fines_normalised_from_sip2_fee_amount
* test_sip2_info_takes_precedence_over_patron_model
…rocess.

Also fixes poetry lock file merge conflict.
@dbernstein dbernstein force-pushed the feature/PP-3772-Add-patron-blocking-rules-per-library branch from 40c4ef5 to 98d9c5e Compare March 5, 2026 21:18
@dbernstein dbernstein requested review from a team and removed request for a team March 6, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant