mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
A role-files-layout exemption frees a tree from files/<lang>/ because its import paths are fixed from outside. The mirrored-test lint derived its scope from that same directory, so every layout exemption silently became a test exemption nobody ever argued for. The language now comes from the file suffix. Test code is excluded by scope rather than exempted: a test-* role and a files/playwright/ or files/test/ tree are the tests, so demanding a test of them is circular. The eleven files this newly reaches are framework-instantiated and carry a stated reason each.
239 lines
7.9 KiB
PHP
239 lines
7.9 KiB
PHP
<?php
|
|
// nocheck: mirrored-unit-test - registers add_action hooks at load and maps roles through WordPress user objects the core supplies
|
|
/**
|
|
* Plugin Name: Infinito.Nexus OIDC RBAC Role Mapper
|
|
* Description: Maps the Keycloak `groups` claim delivered by
|
|
* daggerhart/openid-connect-generic to a WordPress role.
|
|
* Installed as a mu-plugin so it is always active and
|
|
* cannot be deactivated from the admin UI.
|
|
* Version: 2.0.0
|
|
* Author: Infinito.Nexus
|
|
*
|
|
* Requirements:
|
|
* - docs/requirements/004-generic-rbac-ldap-auto-provisioning.md
|
|
* - docs/requirements/005-wordpress-multisite-rbac.md
|
|
*
|
|
* Contract:
|
|
* - Each WordPress user's role is derived purely from the `groups` claim
|
|
* of the OIDC userinfo response. No direct LDAP bind from WordPress.
|
|
* - The claim uses the hierarchical Keycloak group path introduced by
|
|
* requirement 005:
|
|
* /roles/web-app-wordpress/<role> (single-site)
|
|
* /roles/web-app-wordpress/<tenant>/<role> (multisite per-site)
|
|
* /roles/web-app-wordpress/network-administrator (multisite global)
|
|
* - Groups for other apps (e.g. /roles/web-app-discourse/editor) are
|
|
* ignored here.
|
|
* - When the user matches multiple roles for the same scope, the
|
|
* highest-privilege role wins
|
|
* (administrator > editor > author > contributor > subscriber).
|
|
* - When no claim entry matches, the weakest role `subscriber` is
|
|
* assigned as a deterministic fallback. An unauthenticated claim
|
|
* MUST NOT silently elevate rights.
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
add_action('openid-connect-generic-user-create', 'infinito_oidc_map_rbac_role', 10, 2);
|
|
add_action(
|
|
'openid-connect-generic-update-user-using-current-claim',
|
|
'infinito_oidc_map_rbac_role',
|
|
10,
|
|
2
|
|
);
|
|
|
|
const INFINITO_RBAC_APP_ID = 'web-app-wordpress';
|
|
const INFINITO_RBAC_NETWORK_ADMIN_ROLE = 'network-administrator';
|
|
const INFINITO_RBAC_OWNERSHIP_META_KEY = '_infinito_oidc_added_blog_ids';
|
|
|
|
/**
|
|
* WP roles in descending privilege order. Must match the requirement 004
|
|
* priority rule exactly; changes here affect the mapping contract.
|
|
*/
|
|
function infinito_rbac_priority() {
|
|
return array(
|
|
'administrator',
|
|
'editor',
|
|
'author',
|
|
'contributor',
|
|
'subscriber',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse the `groups` claim and return a structured view:
|
|
* array(
|
|
* 'global' => [ <role>, ... ],
|
|
* 'per_tenant' => [ <tenant> => [ <role>, ... ] ],
|
|
* 'network_admin' => bool,
|
|
* )
|
|
*
|
|
* Requirement 005 mandates a hierarchical Keycloak group path that
|
|
* mirrors the LDAP RBAC tree:
|
|
* /<rbac-group-root>/<application_id>/<role> (single-site or global)
|
|
* /<rbac-group-root>/<application_id>/<tenant>/<role> (per-tenant)
|
|
* /<rbac-group-root>/<application_id>/network-administrator (multisite super-admin)
|
|
* Only entries whose second segment equals INFINITO_RBAC_APP_ID are
|
|
* considered; everything else is silently dropped.
|
|
*/
|
|
function infinito_rbac_parse_claim($groups) {
|
|
$parsed = array(
|
|
'global' => array(),
|
|
'per_tenant' => array(),
|
|
'network_admin' => false,
|
|
);
|
|
if (!is_array($groups)) {
|
|
return $parsed;
|
|
}
|
|
foreach ($groups as $group) {
|
|
if (!is_string($group)) {
|
|
continue;
|
|
}
|
|
$segments = array_values(array_filter(
|
|
explode('/', trim($group, '/')),
|
|
function ($s) { return $s !== ''; }
|
|
));
|
|
if (count($segments) < 3) {
|
|
continue;
|
|
}
|
|
if ($segments[1] !== INFINITO_RBAC_APP_ID) {
|
|
continue;
|
|
}
|
|
if (count($segments) === 3) {
|
|
$role = $segments[2];
|
|
if ($role === INFINITO_RBAC_NETWORK_ADMIN_ROLE) {
|
|
$parsed['network_admin'] = true;
|
|
continue;
|
|
}
|
|
$parsed['global'][] = $role;
|
|
} elseif (count($segments) === 4) {
|
|
$tenant = strtolower($segments[2]);
|
|
$role = $segments[3];
|
|
if (!isset($parsed['per_tenant'][$tenant])) {
|
|
$parsed['per_tenant'][$tenant] = array();
|
|
}
|
|
$parsed['per_tenant'][$tenant][] = $role;
|
|
}
|
|
}
|
|
return $parsed;
|
|
}
|
|
|
|
function infinito_rbac_pick_highest_role($roles) {
|
|
foreach (infinito_rbac_priority() as $candidate) {
|
|
if (in_array($candidate, $roles, true)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Single-Site mapping path (requirement 004 contract, rewritten for the
|
|
* hierarchical path from requirement 005). Treats `global` entries as
|
|
* the current-site role source.
|
|
*/
|
|
function infinito_rbac_apply_single_site($user, $parsed) {
|
|
$matched = array();
|
|
foreach ($parsed['global'] as $role) {
|
|
if (in_array($role, infinito_rbac_priority(), true)) {
|
|
$matched[] = $role;
|
|
}
|
|
}
|
|
$selected = infinito_rbac_pick_highest_role($matched);
|
|
if ($selected === null) {
|
|
$selected = 'subscriber';
|
|
}
|
|
$user->set_role($selected);
|
|
}
|
|
|
|
/**
|
|
* Multisite mapping path. Per requirement 005:
|
|
* - network-administrator group grants super-admin across the network;
|
|
* its absence revokes super-admin.
|
|
* - per-tenant entries grant per-site roles, add missing site
|
|
* memberships, and withdraw memberships the mu-plugin added earlier.
|
|
*/
|
|
function infinito_rbac_apply_multisite($user, $parsed) {
|
|
$user_id = (int) $user->ID;
|
|
|
|
if ($parsed['network_admin']) {
|
|
grant_super_admin($user_id);
|
|
} else {
|
|
revoke_super_admin($user_id);
|
|
}
|
|
|
|
$owned = get_user_meta($user_id, INFINITO_RBAC_OWNERSHIP_META_KEY, true);
|
|
if (!is_array($owned)) {
|
|
$owned = array();
|
|
}
|
|
|
|
$sites = function_exists('get_sites') ? get_sites() : array();
|
|
foreach ($sites as $site) {
|
|
$blog_id = (int) $site->blog_id;
|
|
$tenant = strtolower(trim($site->domain));
|
|
$claimed_roles = isset($parsed['per_tenant'][$tenant])
|
|
? $parsed['per_tenant'][$tenant]
|
|
: array();
|
|
$selected = infinito_rbac_pick_highest_role($claimed_roles);
|
|
|
|
if ($selected !== null) {
|
|
if (!is_user_member_of_blog($user_id, $blog_id)) {
|
|
add_user_to_blog($blog_id, $user_id, $selected);
|
|
$owned[] = $blog_id;
|
|
} else {
|
|
switch_to_blog($blog_id);
|
|
$user_for_blog = new WP_User($user_id);
|
|
$user_for_blog->set_role($selected);
|
|
restore_current_blog();
|
|
}
|
|
} else {
|
|
if (
|
|
is_user_member_of_blog($user_id, $blog_id)
|
|
&& in_array($blog_id, $owned, true)
|
|
) {
|
|
remove_user_from_blog($user_id, $blog_id);
|
|
$owned = array_values(array_filter(
|
|
$owned,
|
|
function ($id) use ($blog_id) { return $id !== $blog_id; }
|
|
));
|
|
} elseif (is_user_member_of_blog($user_id, $blog_id)) {
|
|
switch_to_blog($blog_id);
|
|
$user_for_blog = new WP_User($user_id);
|
|
$user_for_blog->set_role('subscriber');
|
|
restore_current_blog();
|
|
}
|
|
}
|
|
}
|
|
|
|
update_user_meta(
|
|
$user_id,
|
|
INFINITO_RBAC_OWNERSHIP_META_KEY,
|
|
array_values(array_unique($owned))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Map OIDC `groups` claim to a WordPress role.
|
|
*
|
|
* @param \WP_User $user The user whose role is being set.
|
|
* @param array $user_claim The OIDC claims as delivered by the IDP.
|
|
*/
|
|
function infinito_oidc_map_rbac_role($user, $user_claim) {
|
|
if (!($user instanceof WP_User)) {
|
|
return;
|
|
}
|
|
if (!is_array($user_claim)) {
|
|
$user->set_role('subscriber');
|
|
return;
|
|
}
|
|
$groups = isset($user_claim['groups']) ? $user_claim['groups'] : array();
|
|
$parsed = infinito_rbac_parse_claim($groups);
|
|
|
|
if (function_exists('is_multisite') && is_multisite()) {
|
|
infinito_rbac_apply_multisite($user, $parsed);
|
|
return;
|
|
}
|
|
infinito_rbac_apply_single_site($user, $parsed);
|
|
}
|