Skip to content

Commit 4efe2e8

Browse files
pattonwebzclaude
andcommitted
Add User_Capability_Grant for direct per-user capability grants
William asked whether a capability could be granted to an individual user (not just via role), and whether an admin could see who granted it. Synced_Capability doesn't need to change for this: its sync() only touches role objects (wp_user_roles option), while a capability added directly to a user via $user->add_cap() lives in that user's own wp_capabilities meta — separate storage that current_user_can()/ user_can() already merges. A role-level sync can never clobber an individual grant. What WordPress doesn't provide is attribution — no concept of who granted a capability or when. User_Capability_Grant adds that as a thin layer: grant()/revoke() wrap add_cap()/remove_cap() and record {granted_by, granted_at} in user meta; get_grant_info() surfaces it for a future admin UI; is_individually_granted() distinguishes "granted directly to this user" from "has it via their role" (both pass user_can() identically, only the former reads $user->caps rather than the role-merged $user->allcaps). Works with any capability string, not coupled to edac_ignore_issues or Synced_Capability. No admin UI yet — this is the underlying mechanism a future grant/revoke screen would call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 290778b commit 4efe2e8

1 file changed

Lines changed: 123 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
/**
3+
* Class file for directly granting a capability to an individual user, with
4+
* attribution.
5+
*
6+
* @package Accessibility_Checker
7+
*/
8+
9+
namespace EqualizeDigital\AccessibilityChecker\Capabilities;
10+
11+
/**
12+
* Grants (or revokes) a capability on one specific user, independent of
13+
* their role, and records who granted it and when.
14+
*
15+
* Deliberately separate from Synced_Capability rather than a change to it:
16+
* Synced_Capability's sync() only ever calls add_cap()/remove_cap() on role
17+
* objects (stored in the wp_user_roles option), never on an individual
18+
* user's own capability list (stored in that user's wp_capabilities user
19+
* meta) — those are different storage, so a role-level sync can never
20+
* clobber a capability granted directly to one user, and this class never
21+
* needs to know anything about how (or whether) a capability is synced
22+
* onto roles elsewhere. It works with any capability string, custom or
23+
* core.
24+
*
25+
* WordPress's own current_user_can()/user_can() already merges role
26+
* capabilities with a user's own directly-added capabilities — that part
27+
* needs no new code. What WordPress has no concept of is *who* granted a
28+
* capability and *when*; that attribution is what this class adds.
29+
*/
30+
class User_Capability_Grant {
31+
32+
/**
33+
* User meta key prefix under which grant attribution is stored, per
34+
* capability.
35+
*
36+
* @var string
37+
*/
38+
private const META_PREFIX = 'edac_capability_grant_';
39+
40+
/**
41+
* Grant a capability directly to a user, recording who granted it.
42+
*
43+
* @param int $user_id User to grant the capability to.
44+
* @param string $capability Capability string to grant.
45+
* @param int $granted_by User ID of the granter. Defaults to the current user.
46+
* @return bool True if the user was found and the grant was recorded.
47+
*/
48+
public static function grant( int $user_id, string $capability, int $granted_by = 0 ): bool {
49+
$user = get_userdata( $user_id );
50+
if ( ! $user ) {
51+
return false;
52+
}
53+
54+
$user->add_cap( $capability );
55+
56+
update_user_meta(
57+
$user_id,
58+
self::META_PREFIX . $capability,
59+
[
60+
'granted_by' => $granted_by ? $granted_by : get_current_user_id(),
61+
'granted_at' => time(),
62+
]
63+
);
64+
65+
return true;
66+
}
67+
68+
/**
69+
* Revoke a capability that was granted directly to a user (does not
70+
* affect a capability the user has via their role).
71+
*
72+
* @param int $user_id User to revoke the capability from.
73+
* @param string $capability Capability string to revoke.
74+
* @return bool True if the user was found and the revoke was applied.
75+
*/
76+
public static function revoke( int $user_id, string $capability ): bool {
77+
$user = get_userdata( $user_id );
78+
if ( ! $user ) {
79+
return false;
80+
}
81+
82+
$user->remove_cap( $capability );
83+
delete_user_meta( $user_id, self::META_PREFIX . $capability );
84+
85+
return true;
86+
}
87+
88+
/**
89+
* Get attribution for a capability directly granted to a user via
90+
* grant(), for display in an admin UI (e.g. "Granted by X on Y").
91+
* Returns null if the capability was never granted through this class
92+
* (including if the user only has it via their role).
93+
*
94+
* @param int $user_id User to check.
95+
* @param string $capability Capability string to check.
96+
* @return array{granted_by: int, granted_at: int}|null
97+
*/
98+
public static function get_grant_info( int $user_id, string $capability ): ?array {
99+
$meta = get_user_meta( $user_id, self::META_PREFIX . $capability, true );
100+
101+
return $meta ? $meta : null;
102+
}
103+
104+
/**
105+
* Whether a capability was added directly to this user (as opposed to
106+
* inherited from one of their roles).
107+
*
108+
* $user->caps holds only what was assigned directly to this user (role
109+
* slugs plus any directly-added capabilities); $user->allcaps is the
110+
* merged result including everything resolved from their roles. Using
111+
* $user->caps here is what makes this "direct grant," not "has it at
112+
* all," correctly distinct from user_can()/current_user_can().
113+
*
114+
* @param int $user_id User to check.
115+
* @param string $capability Capability string to check.
116+
* @return bool
117+
*/
118+
public static function is_individually_granted( int $user_id, string $capability ): bool {
119+
$user = get_userdata( $user_id );
120+
121+
return $user instanceof \WP_User && ! empty( $user->caps[ $capability ] );
122+
}
123+
}

0 commit comments

Comments
 (0)