Closes #22429: Enforce ObjectPermission constraints on grant_token#22424
Closes #22429: Enforce ObjectPermission constraints on grant_token#22424bctiemann wants to merge 3 commits into
Conversation
has_perm('users.grant_token', obj=None) short-circuits to True in
ObjectPermissionMixin.has_perm whenever the permission is present in the
cache, regardless of constraints (obj=None causes early return at line 137
before constraint evaluation runs). An operator holding any constrained
grant_token ObjectPermission could create tokens for any user, including
admins with higher privilege, bypassing the constraint entirely.
Fix: replace has_perm(obj=None) with a new helper _user_may_grant_token()
that:
1. Confirms the requesting user holds the users.grant_token permission at all.
2. Treats an empty/null constraint as unconstrained (existing semantics).
3. For constrained permissions, remaps Token-model constraint keys to User-
model keys ('user__<field>' → '<field>', 'user' → 'pk') and evaluates them
directly against the target User record. This is correct because grant_token
constraints express which users tokens may be created for, and we are
checking a new (unsaved) token whose only relevant attribute is its owner.
Non-user Token fields in a constraint are treated as fail-closed (deny).
Regression test: asserts that a {'user__is_superuser': False} constraint
denies token creation for a superuser but allows it for a regular user.
Ref: SR-001 / VM-322 (internal security review, CWE-863)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@claude /review |
|
Claude finished @bctiemann's task in 3m 47s —— View job Code Review: Security: enforce ObjectPermission constraints on grant_token
OverviewThis PR correctly identifies and addresses a real privilege escalation: Critical Issue: Superuser Regression
The root cause: # ObjectPermissionMixin.has_perm — has the bypass
if user_obj.is_active and user_obj.is_superuser:
return TrueSince
The original Fix: mirror def _user_may_grant_token(requesting_user, token_user):
# Mirrors ObjectPermissionMixin.has_perm: superusers implicitly have all permissions.
if requesting_user.is_active and requesting_user.is_superuser:
return True
...Medium Issues1. Brittle private API coupling (
The safer approach would be to use the already-public 2.
if hasattr(val, 'pk'):
val = val.pkConstraints are stored as JSON, so values are always primitives (strings, booleans, integers). The Test Coverage GapsThe regression test for the constrained permission case is correct and meaningful. However:
Minor: Constraint Value Path
SummaryThe vulnerability identification and the constraint-remapping approach are correct. The implementation has one critical regression (superusers blocked from granting tokens), a fragile dependency on a private cache attribute, and missing test coverage for the superuser and edge-case paths. The superuser issue must be addressed before merging; the others are worth fixing but not blocking. |
…tests
- Add superuser early-return at the top of _user_may_grant_token(),
mirroring ObjectPermissionMixin.has_perm's bypass. Superusers without
an explicit grant_token ObjectPermission were getting 403 (regression).
- Access _object_perm_cache via getattr() with a safe default instead of
direct attribute access, guarding against non-standard backends.
- Remove dead hasattr(val, 'pk') branch: constraint values are always JSON
primitives; the substitution maps to requesting_user.pk (int).
- Use str.removeprefix('user__') instead of key[len('user__'):].
- Add three tests: superuser always allowed, self-only ($user) constraint,
non-user Token field constraint fails closed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
arthanson
left a comment
There was a problem hiding this comment.
This works bug Claude is saying the same has_perm('users.grant_token')-with-no-obj pattern exists at two other token-creation sites, and both have the identical privilege-escalation bug the PR is fixing:
- users/views.py:89 - TokenBulkImportView.save_object() (bulk CSV import)
- users/forms/model_forms.py:198 - TokenForm.clean() (web UI create form)
An operator with a constrained grant_token (e.g. {"user__is_superuser": False}) can still mint a token for a superuser through the UI or bulk import, exactly as described in #22429 - just not through the REST API anymore.
Closes: #22429
Summary
Fixes a privilege escalation where constrained
grant_tokenObjectPermissions were silently bypassed in the REST API (SR-001 / VM-322, CWE-863).Root cause
TokenSerializer.validate()calledrequest.user.has_perm('users.grant_token')with noobjargument. InObjectPermissionMixin.has_perm,obj=Nonecauses an unconditional early return ofTrueonce the permission is found in cache — constraints are never evaluated.An operator holding a constrained
grant_tokenObjectPermission (e.g.{"user__is_superuser": False}) could supply anyuserin a token-creation request and the constraint would never fire, allowing tokens to be minted for privileged users.Fix
Replace
has_perm(obj=None)with a new helper_user_may_grant_token(requesting_user, token_user)that:Trueimmediately for superusers (mirrorsObjectPermissionMixin.has_perm's bypass).users.grant_tokenat all.user__<field>→<field>,user→pk) and evaluates them directly against the target User record — the only variable in a new-token creation scenario. Non-user Token fields are treated as fail-closed (deny).We cannot use
has_perm(obj=token_instance)with an existing Token because the token does not exist yet, and passing an unsaved instance returns empty frommodel.objects.filter(pk=None).Tests
test_grant_token_constrained_permission_is_enforced—{"user__is_superuser": False}denies superuser target, allows non-superuser target.test_grant_token_superuser_always_allowed— superuser can grant tokens without an explicit ObjectPermission.test_grant_token_self_only_constraint—{"user": "$user"}blocks cross-user grants.test_grant_token_non_user_field_constraint_fails_closed— non-user Token field constraints fail closed for unsaved tokens.Ref: SR-001 / VM-322 (internal security review, not an externally-reported disclosure)
🤖 Generated with Claude Code