Tighten Admin Validations + IDOR Prevention#1641
Conversation
…ck token at-rest End-to-end audit of every Auth0-touching authorization pathway. Twelve verified findings shipped; agent-reported false positives (intentional IDOR-safe SetCorpusVisibility, AnnotationImagesView, moderation mutations, moderation_metrics, etc.) verified in source and dropped. - F1: AUTH0_SUPERUSER_SUB_ALLOWLIST gates is_superuser elevation in sync_admin_claims_from_payload regardless of JWT claim, blocking the user_metadata-sourced privilege escalation path. New users.W001 system check warns on empty allowlist when USE_AUTH0=True. - F2: ADMIN_CLAIMS_CACHE_TTL 300s -> 30s, tightening the privilege retention window after Auth0 demotion. - F3+F4: AddRelationship now uses Annotation/Corpus visible_to_user with count comparison and a single unified not-found message; broad except no longer surfaces ORM text. - F5+F6: CreateMetadataColumn and UpdateMetadataColumn use visible_to_user + unified not-found-or-no-permission message. - F7: Datacell mutations catch DoesNotExist explicitly and stop echoing ORM exception text. - F9: Bounded JWKS stale-cache fallback (1h grace past TTL); fails closed beyond the window so a key compromise + Auth0 outage cannot let the old key keep verifying tokens indefinitely. - F10: Analysis.callback_token is now stored as SHA-256 hex (callback_token_hash). rotate_callback_token() returns a fresh plaintext at submit time; verify_callback_token() does the constant-time check on incoming requests. Migration backfills existing UUID tokens by hash so in-flight analyzers continue to authenticate. Submit log redacted. - F8+F11: Documentation-only. Step 7 walkthrough for the allowlist, env-var rows for AUTH0_SUPERUSER_SUB_ALLOWLIST + AUTH0_CREATE_NEW_USERS, app_metadata-only requirement called out in danger callout, inline comment that User.email is informational (not an identity field). Operator action required before deploy: populate AUTH0_SUPERUSER_SUB_ALLOWLIST with the Auth0 sub of every user who must retain Django is_superuser. Existing superusers whose subs are not in the allowlist will be demoted within ~30s of their next API call.
| # protects against misconfigured tenant Actions that source admin claims | ||
| # from user-writable ``user_metadata``. Existing superusers whose subs are | ||
| # not added here are demoted on next sync; populate this BEFORE deploy. | ||
| AUTH0_SUPERUSER_SUB_ALLOWLIST = env.list( |
Code Review: Security Hardening — Auth0 Permissioning Audit & Callback Token HashingThis is a well-scoped, defence-in-depth security PR. The changes are correct in intent and largely well-implemented. Below is a detailed breakdown. Overall AssessmentPositive
Issues & SuggestionsMedium — Document permission gap in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Code Review: Tighten Admin Validations + IDOR PreventionThis is a well-structured, defense-in-depth security PR. The changes are clearly motivated, consistently applied, and come with good test coverage. Below is a thorough review. OverviewThe PR addresses three distinct security layers:
All three areas contain real security improvements and the changes are generally correct. The following notes cover bugs, edge cases, and minor style issues. Findings1.
|
CI was failing because the AUTH0_SUPERUSER_SUB_ALLOWLIST gate (added in this PR) blocks is_superuser elevation, but several admin-auth tests were still asserting elevation without populating the allowlist. The test_metadata_columns_graphql test was also still asserting the old "don't have permission" string, which the PR replaced with the IDOR-safe unified message. Test updates (failing -> passing): - test_admin_auth.TestAdminClaimsSync.test_sync_is_superuser_true_from_claims - test_admin_auth.TestAdminClaimsSync.test_sync_both_claims - test_admin_auth.TestGetUserByPayloadWithClaimSync.test_sync_claims_cached_handles_cache_failure - test_metadata_columns_graphql.test_create_metadata_column_without_permission Address Claude review findings: - (#2) AUTH0_ADMIN_CLAIMS_CACHE_TTL is now configurable via env var; the module constant stays the default. - (#4) Reverse migration 0021 uses QuerySet.update() instead of model.save() so historical-model signals do not fire on downgrade. - (#5) Token hash backfill iterates with chunk_size=500 to bound memory. - (#7) Wire AUTH0_CREATE_NEW_USERS into AUTH0_JWT from env so deployments can disable user auto-provisioning without editing settings. - (#8) Silence users.W001 in test settings so the deliberate empty-allowlist tests don't print system-check noise. - (#1) _can_serve_stale now asserts it is called under the JWKS lock and carries a prominent docstring warning. New tests added to cover patch lines lacking coverage: - TestAuth0SuperuserAllowlist (3 tests) — empty allowlist, sub not in allowlist, and demote-bypasses-allowlist paths. - TestAuth0SuperuserAllowlistSystemCheck (3 tests) — direct unit tests for users.checks.check_auth0_superuser_allowlist. - TestAnalysisCallbackTokenHelpers (6 tests) — direct unit tests for Analysis.rotate_callback_token / verify_callback_token covering empty hash, None/empty candidate, plaintext-not-stored, and rotation invalidating prior plaintexts. - TestCanServeStale (3 tests) — direct unit tests for _can_serve_stale (no-data, inside-grace, outside-grace). - test_network_error_after_stale_grace_fails_closed and test_json_error_after_stale_grace_fails_closed — JWKS fails closed outside the stale-grace window. - test_admin_claims_cache_ttl_env_override and test_admin_claims_cached_noop_when_auth0_disabled — exercise the new env-configurable TTL and the USE_AUTH0=False short-circuit.
Code Review: Tighten Admin Validations + IDOR PreventionThis is a well-structured security PR covering three real vulnerability classes. The fixes are targeted, the migration is safe, and the test coverage for the new paths is thorough. A few items worth addressing before merge: Issues1.
|
The PR introduced explicit Datacell.DoesNotExist branches in ApproveDatacell / RejectDatacell / EditDatacell so the broad-except no longer leaks ORM text. These paths were not covered by the existing happy-path datacell tests. Adds four tests: - approve / reject / edit returning the unified Datacell not found. message for an unknown id - approve on a datacell owned by another user (queryset filters by creator, so DoesNotExist fires) returning the same message — verifies the IDOR-safe property end to end
Code Review: Tighten Admin Validations + IDOR PreventionThis is a high-quality security hardening PR. The changes are well-scoped, consistently applied, and the test suite is thorough. Several issues worth raising are below. What's Working Well
IssuesLow —
|
| Check | Result |
|---|---|
count() != len(set(source_pks)) handles duplicate IDs in input without echoing them |
✓ |
hmac.compare_digest compares two equal-length hex strings (64 chars each) — constant-time guarantee holds |
✓ |
| Migration forward path hashes before dropping column; reverse is documented as lossy | ✓ |
ADMIN_CLAIMS_CACHE_TTL = 30 propagates to auth.py, _sync_admin_claims_cached, and all doc references |
✓ |
_can_serve_stale is only invoked inside with _jwks_cache_lock: in _get_cached_jwks |
✓ |
Allowlist gate only blocks elevation (is_superuser_claim=True); demotion is unaffected |
✓ |
SILENCED_SYSTEM_CHECKS = ["users.W001"] in test settings prevents noise; allowlist tests use override_settings and call the check function directly |
✓ |
Summary
No blocking issues. The assert → RuntimeError change and the @register(Tags.security) tag are the most actionable items. Everything else is low/minor polish. Ready to merge once these are addressed or consciously accepted.
The PR's AddRelationship mutation collapses both unparseable global IDs (caught by the outer try/except) and unknown but well-formed IDs (caught by the visible_to_user count comparison) into a single not-found message. Adds two tests: - test_garbage_global_ids_yield_not_found_message: a base64-decodable but bogus global ID exercises the outer broad-except branch. - test_unknown_source_annotation_id_yields_not_found: a syntactically valid global ID pointing at no annotation exercises the count-mismatch branch. Both paths must produce the unified not-found message so the response shape gives no enumeration oracle.
Code Review — PR #1641: Tighten Admin Validations + IDOR PreventionThis is a solid, well-scoped security hardening PR. The changes are targeted, the rationale is clearly documented, and the test coverage is genuinely good. Below are observations ranging from blocking concerns to minor style notes. OverviewThree distinct security layers are addressed:
All three areas have correct core logic. The issues below are mostly about edge cases, defence-in-depth gaps, and a few correctness risks. Potential Bugs / Correctness Issues1.
|
| # | Severity | Area | Action |
|---|---|---|---|
| 1 | Low | _can_serve_stale lock assert |
Clarify or remove misleading assert |
| 2 | Low | Migration backfill N+1 | Use bulk_update for production-scale safety |
| 3 | Medium | AddRelationship relationship_label visibility |
Add visible_to_user check for consistency |
| 4 | Medium | AUTH0_JWT dict assignment |
Verify no existing keys are silently dropped |
| 5 | Info | is_staff allowlist gap |
Document in code or defer to follow-up |
| 6 | Low | callback_token_hash = "" write bypass |
Consider clean() guard |
| 7–11 | Info | Style / test clarity | Optional polish |
Overall this is well-thought-out work. The IDOR fixes, token hashing, and allowlist mechanism are all correct in their core logic. Items 3 and 4 are the most worth addressing before merge.
Code Review — PR #1641: Tighten Admin Validations + IDOR PreventionOverall this is a well-scoped, defense-in-depth security PR with clear rationale for each fix. The IDOR-safe error messaging, token hashing, and allowlist gate are all solid. A few concerns worth addressing before merge, ordered by severity. Critical / High1.
assert _jwks_cache_lock.locked(), "_can_serve_stale must be called while holding _jwks_cache_lock"
if not _jwks_cache_lock.locked():
raise RuntimeError("_can_serve_stale must be called while holding _jwks_cache_lock")This is better than 2. The PR refactors the exception handling in these mutations but the underlying queryset is: obj = Datacell.objects.get(pk=pk)There is no Medium3. The docs and CHANGELOG do call this out, but the behavior is asymmetric: the system check (
At minimum the hint message should say explicitly: "Existing superusers will be demoted on their NEXT REQUEST, not just on next login." 4. N+1 queries in source_annotations = Annotation.objects.visible_to_user(user).filter(id__in=source_pks)
target_annotations = Annotation.objects.visible_to_user(user).filter(id__in=target_pks)
if source_annotations.count() != len(set(source_pks)) or target_annotations.count() != len(set(target_pks)):
...
relationship.target_annotations.set(target_annotations)
relationship.source_annotations.set(source_annotations)This is four database round-trips (two visible_source_ids = set(Annotation.objects.visible_to_user(user).filter(id__in=source_pks).values_list('id', flat=True))
if visible_source_ids != set(source_pks): ...Then pass the already-fetched IDs to 5. # config/settings/base.py
AUTH0_JWT = {
"AUTH0_CREATE_NEW_USERS": env.bool("AUTH0_CREATE_NEW_USERS", default=True),
}If an 6. The PR description says this is "wired into Low / Nitpick7. Import inside settings module body # config/settings/base.py
from opencontractserver.constants.auth import (
ADMIN_CLAIMS_CACHE_TTL as _DEFAULT_ADMIN_CLAIMS_CACHE_TTL,
)
AUTH0_ADMIN_CLAIMS_CACHE_TTL = env.int("AUTH0_ADMIN_CLAIMS_CACHE_TTL", default=_DEFAULT_ADMIN_CLAIMS_CACHE_TTL)This is valid Python but it creates a settings-time import of application code, which can trigger circular imports if Django hasn't fully initialized. The safer approach is to hard-code the default ( 8. Rotation-on-every-submit vs. pre-existing in-flight callbacks
9. Test assertion In result = sync_admin_claims_from_payload(self.user, payload)
self.assertTrue(result)If the user starts as self.assertFalse(self.user.is_superuser) # already done below — the assertTrue is redundant or misleading10. SHA-256 token hashing without salt (informational) Using unsalted SHA-256 for the callback token is acceptable here because Positives Worth Calling Out
|
| ADMIN_CLAIMS_CACHE_TTL as _DEFAULT_ADMIN_CLAIMS_CACHE_TTL, | ||
| ) | ||
|
|
||
| AUTH0_ADMIN_CLAIMS_CACHE_TTL = env.int( |
| # out-of-band provisioning (any unknown sub will fail authentication). | ||
| # Consumed by ``config.graphql_auth0_auth.utils.get_auth0_user_from_token`` | ||
| # via the ``AUTH0_JWT`` settings dict. | ||
| AUTH0_JWT = { |
Latest Claude review: - (#1) Lock assert was misleading because threading.Lock.locked() returns True if any thread holds the lock, not the caller. Replaced the assert with a sharper docstring note that documents the limitation and points future refactors at threading.RLock. - (#2 / #10) Migration backfill and reverse now use bulk_update batches to keep DB round-trips bounded. - (#3) AddRelationship now visibility-checks relationship_label_pk too, closing the residual oracle where an attacker could probe private AnnotationLabel IDs. - (#5) Added an inline comment at the allowlist gate documenting why is_staff is intentionally NOT allowlist-gated and how to layer one. - (#7) test.py SILENCED_SYSTEM_CHECKS comment now cross-references the direct-call test that bypasses the silencing. - (#9) submit_corpus_documents_to_analyzer logging switched from f-string to %-style for consistency with the new logger.exception calls. New test: - test_inaccessible_relationship_label_yields_not_found verifies the new label visibility check returns the unified not-found message and blocks the Relationship row from being created.
Earlier Claude review notes: - rotate_callback_token now auto-saves when self.pk is set, so callers can no longer ship a plaintext to the worker while leaving the DB hash stale. Unsaved-instance behaviour is unchanged (caller still owns the first save). Two new tests pin both branches. - check_auth0_superuser_allowlist is now registered under Tags.security so it shows up under ``manage.py check --tag security``. - logger.exception(...) calls in annotation_mutations and extract_mutations no longer pass ``e``; ``logger.exception`` captures the traceback automatically and the extra arg double-logged the exception text.
Code Review — PR #1641: Tighten Admin Validations + IDOR PreventionThis is a well-scoped security hardening PR covering three independent attack surfaces. The overall approach is sound and the changelog is unusually thorough. Below are findings organized by severity. High Priority1. Non-integer PKs from In # This .count() triggers a DB query — Django will try to cast each pk to BigInt.
# A non-integer pk (e.g. "not-an-int") raises ValueError or DataError here,
# which is currently unhandled.
if source_annotations.count() != len(set(source_pks)) ...The test 2. The new if (
not AnnotationLabel.objects.visible_to_user(user)
.filter(pk=relationship_label_pk)
.exists()
):This is a good security improvement, but Medium Priority3. In AUTH0_JWT = {
"AUTH0_CREATE_NEW_USERS": env.bool("AUTH0_CREATE_NEW_USERS", default=True),
}If AUTH0_JWT = {
**getattr(locals(), "AUTH0_JWT", {}),
"AUTH0_CREATE_NEW_USERS": env.bool("AUTH0_CREATE_NEW_USERS", default=True),
}Or verify 4. The PR description and CHANGELOG say 5. Migration lacks
Low Priority6. The docstring correctly warns that callers must hold 7. Minor N+1 in source_annotations = Annotation.objects.visible_to_user(user).filter(id__in=source_pks)
...
if source_annotations.count() != len(set(source_pks)): # COUNT query
...
relationship.source_annotations.set(source_annotations) # SELECT query again
8.
Positive Observations
Summary: Items 1 and 2 should be addressed before merge. Items 3–5 are medium-risk but worth a follow-up. The security model described in the PR is correct and the implementation is generally high quality. |
Adds the AUTH0_ADMIN_CLAIMS_CACHE_TTL row to the env-var table so operators can find the configurable knob added in this PR.
| """ | ||
|
|
||
| def setup_method(self): | ||
| import config.graphql_auth0_auth.utils as utils_module |
| assert _can_serve_stale(0) is False | ||
|
|
||
| def test_returns_true_inside_grace_window(self): | ||
| import config.graphql_auth0_auth.utils as utils_module |
| assert _can_serve_stale(100 + _JWKS_STALE_GRACE - 1) is True | ||
|
|
||
| def test_returns_false_outside_grace_window(self): | ||
| import config.graphql_auth0_auth.utils as utils_module |
- (#1) AddRelationship now casts each parsed pk to ``int()`` inside the outer try/except so a base64-decodable but non-numeric global ID (``"BogusType:not-an-int"``) fails closed at parse time instead of later at the queryset boundary. Updated test_garbage_global_ids_yield_not_found_message to assert the not-found response directly now that the path is deterministic. - (#5) Migration 0021 now carries a module docstring explaining why the AddField + backfill + RemoveField operations stay in one atomic transaction (partial runs would leave live rows without a hash) and flags the expected lock window for large tables. bulk_update keeps the transaction short. Items addressed from this review but already in place: AnnotationLabel inherits BaseOCModel so ``.objects.visible_to_user()`` works out-of-the-box; AUTH0_CREATE_NEW_USERS is consumed via ``auth0_settings.AUTH0_CREATE_NEW_USERS`` in ``get_auth0_user_from_token`` which reads from the AUTH0_JWT dict; AUTH0_JWT is only assigned in base.py (verified via grep).
Code Review: PR #1641 — Tighten Admin Validations + IDOR PreventionThis is a well-scoped security hardening PR addressing real vulnerabilities across three layers. The implementation is careful and the test coverage is comprehensive. Below are findings ranging from bugs to minor style notes. OverviewThe PR ships twelve security fixes:
The overall approach is correct and the new patterns are consistent with the project's Bugs / Correctness Issues1.
|
| Severity | Finding |
|---|---|
| Bug | ApproveDatacell/RejectDatacell lack creator filter; new test's doc-comment is incorrect (#1) |
| Minor | test_garbage_global_ids has a soft assertion that can silently pass (#2) |
| Low | AUTH0_JWT dict assignment may silently discard existing keys (#5) |
| Low | Import inside settings class body risks circular imports (#6) |
| Nit | _can_serve_stale call site should note lock-must-be-held invariant (#4) |
The Auth0 allowlist, JWKS stale-grace, IDOR-safe messages, and callback token hashing are all solid. Resolving finding #1 (and ideally #2) before merge would close the remaining IDOR surface in the datacell mutations.
Code Review: Tighten Admin Validations + IDOR PreventionThis is a well-scoped security hardening PR covering three distinct attack surfaces. The overall design is sound. Notes below are organized by severity. Potential Bugs / Must-Fix1. AUTH0_JWT dict assignment may clobber existing keys The new code in config/settings/base.py does a plain dict assignment for AUTH0_JWT. If AUTH0_JWT was already defined with other keys in a parent settings file, this silently drops them. Either confirm the variable is brand-new to this codebase, or use a merge pattern. This cannot be ruled out from the diff alone. 2. AnnotationLabel.objects.visible_to_user(user) — manager may not exist config/graphql/annotation_mutations.py now calls AnnotationLabel.objects.visible_to_user(user).filter(...). The visible_to_user method is provided by BaseVisibilityManager. If AnnotationLabel does not inherit BaseOCModel (or lacks that manager), this raises AttributeError at runtime and produces an unhandled 500 — worse than the original permission leak. Please confirm AnnotationLabel has the manager, or fall back to an explicit guardian check. 3. test_approve_datacell_owned_by_other_user_returns_not_found — comment vs. code mismatch The test docstring says "the queryset filters by creator" as the reason DoesNotExist is raised, but the diff for ApproveDatacell and RejectDatacell only shows exception-clause changes. If those mutations do not include creator=info.context.user in the get() call, any authenticated user can approve or reject any datacell. Please show the full get() call in context, or add an inline comment confirming the creator filter exists. Design / Architecture Observations4. Double visibility check in CreateMetadataColumn / UpdateMetadataColumn The pattern (visible_to_user().get() followed by an explicit user_has_permission_for_obj UPDATE check) is correct and intentional: the first avoids leaking whether the corpus exists at all, the second enforces the UPDATE permission specifically. A brief inline comment explaining the two-layer design would prevent a future reader from collapsing it into just the explicit permission check, which would reintroduce the enumeration oracle. 5. Allowlist demotion is a silent breaking change on upgrade With an empty (default) allowlist, every existing Auth0-synced superuser is demoted on their next API request within 30 seconds — no grace period, no dry-run mode. The users.W001 check fires at startup but is a Warning. Consider elevating to Critical when User.objects.filter(is_superuser=True).exists(), or adding a management command listing affected users before deploy. 6. _can_serve_stale threading footgun The docstring correctly notes that no runtime assert can guarantee the caller holds _jwks_cache_lock. Since the helper is only called from inside "with _jwks_cache_lock:", this is safe today. Adding a short comment at the call site would make the invariant harder to violate during future refactors. 7. _JWKS_STALE_GRACE = 3600 seconds A 1-hour stale window means a compromised key that Auth0 has rotated can still verify tokens for up to one hour if Auth0 goes down immediately after the rotation. This is a documented availability-vs-security trade-off worth a one-line operator note for deployments with aggressive key-rotation policies. Minor Notes8. Migration concurrency window — AddField with default="" followed by RunPython backfill means rows briefly have callback_token_hash="". Safe because verify_callback_token returns False for an empty hash. 9. Nested try/except in CreateMetadataColumn — valid and intentional; a one-line comment explaining the two-layer structure would help readers who might try to flatten it. 10. Module-level import inside settings class body (config/settings/base.py) — the import of ADMIN_CLAIMS_CACHE_TTL runs at settings-parse time; verify opencontractserver is on sys.path before app initialization in all deployment environments. What is Well Done
Summary: Items 1 and 2 need confirmation before merge (potential runtime breakage). Item 3 needs clarification. Everything else is advisory. The security intent is solid throughout. |
…moted Per latest review: when ``USE_AUTH0=True`` AND ``AUTH0_SUPERUSER_SUB_ALLOWLIST`` is empty AND at least one is_superuser row already exists, the next claim sync will silently demote that superuser within ~30s of their next API call. The previous Warning was too quiet for this footgun. The check now: - Emits ``users.E001`` (Critical) when an existing superuser would be silently demoted. Critical errors block ``manage.py check --deploy`` so a deploy that would lock out the only admin account fails fast. - Falls back to ``users.W001`` (Warning) when no superuser rows exist, preserving the original advisory behaviour. - Tolerates DB-unreachable contexts (e.g. ``makemigrations``) by catching ORM errors and emitting the lower-severity warning. Added a test class setUp that demotes the migration-created initial superuser so the W001 vs E001 branches can be tested deterministically, plus a new test pinning the E001 path with an explicitly-created superuser. ``users.E001`` is added to SILENCED_SYSTEM_CHECKS in test settings so the elevated severity does not break unrelated test runs that happen to leave a superuser in the DB.
Code Review: PR #1641 — Tighten Admin Validations + IDOR PreventionThis is a well-scoped security hardening PR that addresses real vulnerabilities across three distinct layers. The fixes are correctly designed, the test coverage is strong, and the documentation is thorough. Below are detailed findings. OverviewThe PR delivers:
All twelve stated fixes are verified in the diff. No false fixes were introduced. Positives
Issues1.
|
A new ``urllib3`` type stub release narrowed ``requests.post(..., headers=)`` to ``MutableMapping[str, str | bytes]``. The four call sites below were passing ``dict[str, str]``, which is invariant in the value type and so fails the narrower stub even though ``str`` is a subtype of ``str | bytes``. These mypy errors fail the linter step on ``main`` as well — they are unrelated to PR #1641's security work — but they block the Backend CI's pytest job from running because pytest depends on linter passing. Fix is uniform: keep the local ``dict[str, str]`` typing (matches every caller of ``maybe_add_cloud_run_auth`` so we don't have to widen that shared helper too), and cast to ``Any`` at the ``requests.post/get`` call site so mypy lets the narrower dict satisfy the wider stub. No runtime behaviour changes; the dict is still ``str -> str`` at runtime. Files: - opencontractserver/users/tasks.py (lines 45, 165) - opencontractserver/pipeline/rerankers/cohere_reranker.py (line 132) - opencontractserver/pipeline/parsers/docxodus_parser.py (line 156) - opencontractserver/pipeline/parsers/docling_parser_rest.py (line 344)
Code Review: Tighten Admin Validations + IDOR PreventionThis is a solid, well-structured security hardening PR. The changes are logically organized across three independent layers, test coverage is comprehensive, and the documentation is thorough. Below are observations organized by severity. OverviewThree independent security fixes:
Issues Worth Fixing Before Merge1.
if self.pk is not None:
self.save(update_fields=["callback_token_hash"])The docstring only warns about the "unsaved instance" case (pk is None). It doesn't warn about the reverse: calling 2. Import inside settings class scope may cause circular imports
from opencontractserver.constants.auth import (
ADMIN_CLAIMS_CACHE_TTL as _DEFAULT_ADMIN_CLAIMS_CACHE_TTL,
)This import is inside the 3. The
Either removes the repeated cast and its repeated explanatory comment. Performance Note30-second cache TTL at scale Reducing Minor / Nitpicks4. Migration is correct but the "atomic by default" assumption should be explicit
The comment says "Atomic by design. Add/backfill/drop must be one transaction." PostgreSQL supports DDL transactions, so this is true by default. But consider adding 5. Silencing
SILENCED_SYSTEM_CHECKS = ["users.W001", "users.E001"]The comment correctly explains that the check tests call the function directly and bypass silencing. The concern is cosmetic: silencing 6. A one-hour stale-serve window for JWKS means a compromised Auth0 signing key can still be used to forge tokens for up to an hour after the key is rotated out of the JWKS endpoint. This is a deliberate availability-vs-security tradeoff (as noted in the PR), but the value should be documented alongside the constant with an explicit note about what it protects against and why 3600 was chosen vs a shorter value. What's Done Well
SummaryApprove with the |
Code Review — PR #1641: Tighten Admin Validations + IDOR PreventionThis is a well-scoped security hardening PR with clear reasoning behind each change. The three-layer approach (Auth0 privilege escalation, IDOR-safe mutations, and hashed callback tokens) is coherent and defensible. Below are observations organized by severity. Bugs / Correctness Issues
def check_auth0_superuser_allowlist(
app_configs: Any, **kwargs: Any
) -> list[Warning | Critical]:The from __future__ import annotations
AUTH0_JWT = {
"AUTH0_CREATE_NEW_USERS": env.bool("AUTH0_CREATE_NEW_USERS", default=True),
}The comment says this is consumed by Performance Considerations
Migration Security Observations (All Positive)
Minor Code Quality Notes
SummaryApprove with minor comments. The security reasoning is sound, the code is well-commented, and the test coverage (32 new test methods across 5 test files) covers the critical paths including negative cases and edge cases. Three items to follow up before merge:
|
Summary
Comprehensive security audit of Auth0 integration and analyzer callback handling. Twelve real fixes across three layers: defense-in-depth allowlist for
is_superuserJWT elevation, IDOR-safe error messages in GraphQL mutations, and SHA-256 hashing of analyzer callback tokens to prevent credential leakage via database reads.Key Changes
Auth0 Privilege Escalation Prevention
is_superuser: AddedAUTH0_SUPERUSER_SUB_ALLOWLISTenvironment variable. JWT claim sync now refuses to elevateis_superusertoTrueunless the user's Auth0sub(Djangousername) appears in the allowlist, regardless of verified token claims. Blocks misconfigured tenants sourcing admin claims from user-writableuser_metadata.ADMIN_CLAIMS_CACHE_TTLfrom 300 to 30 seconds for tighter privilege revocation SLA.opencontractserver/users/checks.pyto warn at startup when Auth0 is enabled but allowlist is empty.docs/configuration/authentication.mdabout sourcing claims fromapp_metadata(notuser_metadata) and detailed allowlist setup instructions.IDOR-Safe GraphQL Mutations
AddRelationship,CreateMetadataColumn, andUpdateMetadataColumnmutations now return identical error messages for both "does not exist" and "no permission" cases, preventing enumeration attacks.visible_to_user: Source and target annotations filtered throughAnnotation.objects.visible_to_user(user)with count validation to catch both missing and unauthorized IDs without echoing them back.visible_to_user()querysets; non-existent and inaccessible resources collapse into the same error branch.logger.exception()but return generic "Error creating X" messages to clients.Analyzer Callback Token Security
callback_tokenfield withcallback_token_hash(CharField, max_length=64). Database never stores plaintext; a DB read alone cannot let an attacker forge analyzer callbacks.Analysis.rotate_callback_token()generates freshsecrets.token_urlsafe(32)plaintext on each submission, invalidating any in-flight callbacks bound to previous tokens.Analysis.verify_callback_token(candidate)useshmac.compare_digest()to prevent timing leaks.Supporting Changes
opencontractserver/analyzer/views.pyto callverify_callback_token()instead of direct string comparison.test_analyzers.pyandtest_security_hardening.pyto mint plaintext tokens viarotate_callback_token()before submission.AUTH0_CREATE_NEW_USERSenvironment variable (defaultTrue) to control auto-provisioning of new Auth0 users.User.emailas informational (nounique=Trueconstraint);User.username(Auth0sub) is the only identity field._JWKS_STALE_GRACE = 3600seconds) to bound how long a compromised keyhttps://claude.ai/code/session_01DEQ8YPfGpzKHr1VviKjdpX