mirror of
git://f0xx.org/ac/ac-ms-rbac
synced 2026-07-29 00:58:10 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ac-ms-rbac
|
||||
|
||||
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-rbac`
|
||||
25
composer.json
Normal file
25
composer.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "androidcast/ms-rbac",
|
||||
"description": "Companies and privileges",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"androidcast/platform-php": "dev-next",
|
||||
"androidcast/platform-db": "dev-next"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git://f0xx.org/ac/ac-platform-php"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git://f0xx.org/ac/ac-platform-db"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
config/config.example.php
Normal file
2
config/config.example.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
return ['db' => ['driver' => 'sqlite']];
|
||||
70
public/api/rbac.php
Normal file
70
public/api/rbac.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::canManageRbac()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$action = trim((string) ($_GET['action'] ?? ''));
|
||||
|
||||
if ($method === 'GET' && $action === 'panel') {
|
||||
$panel = RbacAdminRepository::buildPanel();
|
||||
json_out($panel, empty($panel['ok']) ? 403 : 200);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
if ($action === 'set_global_role') {
|
||||
$result = RbacAdminRepository::setGlobalRole(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(string) ($body['role'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'set_company_role') {
|
||||
$result = RbacAdminRepository::setCompanyRole(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(int) ($body['company_id'] ?? 0),
|
||||
(string) ($body['role'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'apply_privilege_set') {
|
||||
$result = RbacAdminRepository::applyPrivilegeSet(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(int) ($body['company_id'] ?? 0),
|
||||
(string) ($body['privilege_set'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'clear_auth_lockouts') {
|
||||
if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$userId = (int) ($body['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_user'], 400);
|
||||
}
|
||||
$deleted = AuthAttempts::clearForUser($userId);
|
||||
json_out(['ok' => true, 'cleared' => $deleted]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
2
public/index.php
Normal file
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// API entry — wire nginx to public/
|
||||
46
sql/migrations/001_rbac_companies.sql
Normal file
46
sql/migrations/001_rbac_companies.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- Phase 1: companies, memberships, devices, report scoping.
|
||||
-- Idempotent on fresh DB; existing installs: prefer app auto-migration (Database::ensureRbacSchema).
|
||||
-- Manual run: mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql
|
||||
|
||||
USE androidcast_crashes;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_companies_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS company_memberships (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
|
||||
permissions_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_membership_user_company (user_id, company_id),
|
||||
KEY idx_membership_company (company_id),
|
||||
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
display_name VARCHAR(256) NULL,
|
||||
manufacturer VARCHAR(128) NULL,
|
||||
model VARCHAR(128) NULL,
|
||||
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms BIGINT NOT NULL,
|
||||
last_seen_at_ms BIGINT NOT NULL,
|
||||
meta_json LONGTEXT NULL,
|
||||
UNIQUE KEY uq_devices_company_external (company_id, external_id),
|
||||
KEY idx_devices_company (company_id),
|
||||
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- reports.company_id / reports.device_id: run 002_reports_company_columns.sql as root (app user cannot ALTER)
|
||||
|
||||
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||
401
src/Rbac.php
Normal file
401
src/Rbac.php
Normal file
@@ -0,0 +1,401 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Phase 1 RBAC: global platform roles + company memberships.
|
||||
* MariaDB-style: global scope (root/platform) vs company scope (owner/admin/member).
|
||||
*/
|
||||
final class Rbac {
|
||||
/** Global roles stored on users.role */
|
||||
public const GLOBAL_ROOT = 'root';
|
||||
public const GLOBAL_PLATFORM_ADMIN = 'platform_admin';
|
||||
/** Legacy alias — treated as platform_admin */
|
||||
public const GLOBAL_ADMIN_LEGACY = 'admin';
|
||||
public const GLOBAL_VIEWER = 'viewer';
|
||||
|
||||
public const COMPANY_OWNER = 'owner';
|
||||
public const COMPANY_ADMIN = 'admin';
|
||||
public const COMPANY_MEMBER = 'member';
|
||||
|
||||
/** @var list<string> */
|
||||
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
|
||||
|
||||
/** Company role => granted actions (unless global admin). */
|
||||
/**
|
||||
* Named privilege sets stored in company_memberships.permissions_json as {"grants":[...]}.
|
||||
* Company role defaults still apply; grants extend (not replace) role actions.
|
||||
*/
|
||||
public const PRIVILEGE_SET_NONE = '';
|
||||
public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user';
|
||||
public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin';
|
||||
public const PRIVILEGE_SET_SHORT_LINKS_VIEWER = 'short_links_viewer';
|
||||
public const PRIVILEGE_SET_SHORT_LINKS_USER = 'short_links_user';
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
public const PRIVILEGE_SETS = [
|
||||
self::PRIVILEGE_SET_REMOTE_ACCESS_USER => [
|
||||
'remote_access_view',
|
||||
'remote_access_operate',
|
||||
],
|
||||
self::PRIVILEGE_SET_REMOTE_ACCESS_ADMIN => [
|
||||
'remote_access_view',
|
||||
'remote_access_operate',
|
||||
'remote_access_admin',
|
||||
],
|
||||
self::PRIVILEGE_SET_SHORT_LINKS_VIEWER => [
|
||||
'short_links_view',
|
||||
],
|
||||
self::PRIVILEGE_SET_SHORT_LINKS_USER => [
|
||||
'short_links_view',
|
||||
'short_links_operate',
|
||||
],
|
||||
];
|
||||
|
||||
private const COMPANY_ROLE_ACTIONS = [
|
||||
self::COMPANY_OWNER => [
|
||||
'view_reports',
|
||||
'edit_tags',
|
||||
'upload_reports',
|
||||
'manage_devices',
|
||||
'manage_company_users',
|
||||
'manage_company_settings',
|
||||
'manage_self',
|
||||
'remote_access_view',
|
||||
'remote_access_operate',
|
||||
'remote_access_admin',
|
||||
'short_links_view',
|
||||
'short_links_operate',
|
||||
],
|
||||
self::COMPANY_ADMIN => [
|
||||
'view_reports',
|
||||
'edit_tags',
|
||||
'upload_reports',
|
||||
'manage_devices',
|
||||
'manage_company_users',
|
||||
'manage_self',
|
||||
'remote_access_view',
|
||||
'remote_access_operate',
|
||||
'remote_access_admin',
|
||||
'short_links_view',
|
||||
'short_links_operate',
|
||||
],
|
||||
self::COMPANY_MEMBER => [
|
||||
'view_reports',
|
||||
'manage_self',
|
||||
'remote_access_view',
|
||||
],
|
||||
];
|
||||
|
||||
public static function normalizeGlobalRole(string $role): string {
|
||||
$role = strtolower(trim($role));
|
||||
if ($role === self::GLOBAL_ADMIN_LEGACY) {
|
||||
return self::GLOBAL_PLATFORM_ADMIN;
|
||||
}
|
||||
return $role;
|
||||
}
|
||||
|
||||
public static function isGlobalAdmin(?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
$role = self::normalizeGlobalRole((string) ($user['role'] ?? ''));
|
||||
return in_array($role, self::GLOBAL_ADMIN_ROLES, true);
|
||||
}
|
||||
|
||||
/** @return list<int>|null null = all companies (global admin) */
|
||||
public static function allowedCompanyIds(?array $user = null): ?array {
|
||||
if (self::isGlobalAdmin($user)) {
|
||||
return null;
|
||||
}
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($user['companies'] ?? [] as $c) {
|
||||
if (isset($c['id'])) {
|
||||
$ids[] = (int) $c['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function activeCompanyId(?array $user = null): ?int {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
$id = (int) ($user['active_company_id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function companyRole(int $userId, int $companyId): ?string {
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT role FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $companyId]);
|
||||
$role = $stmt->fetchColumn();
|
||||
return is_string($role) && $role !== '' ? $role : null;
|
||||
}
|
||||
|
||||
public static function can(string $action, ?int $companyId = null, ?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
if (self::isGlobalAdmin($user)) {
|
||||
return true;
|
||||
}
|
||||
if ($action === 'manage_platform') {
|
||||
return false;
|
||||
}
|
||||
if ($action === 'manage_self') {
|
||||
return true;
|
||||
}
|
||||
$companyId ??= self::activeCompanyId($user);
|
||||
if ($companyId === null || $companyId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$role = self::membershipRoleForUser($user, $companyId);
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
$grants = self::COMPANY_ROLE_ACTIONS[$role] ?? [];
|
||||
if (in_array($action, $grants, true)) {
|
||||
return true;
|
||||
}
|
||||
return self::hasPermissionOverride($user, $companyId, $action);
|
||||
}
|
||||
|
||||
public static function canAccessCompany(int $companyId, ?array $user = null): bool {
|
||||
$allowed = self::allowedCompanyIds($user);
|
||||
if ($allowed === null) {
|
||||
return true;
|
||||
}
|
||||
return in_array($companyId, $allowed, true);
|
||||
}
|
||||
|
||||
public static function canAccessReport(array $report, ?array $user = null): bool {
|
||||
$companyId = (int) ($report['company_id'] ?? 0);
|
||||
if ($companyId <= 0) {
|
||||
return self::isGlobalAdmin($user);
|
||||
}
|
||||
return self::canAccessCompany($companyId, $user);
|
||||
}
|
||||
|
||||
public static function defaultCompanyId(): int {
|
||||
static $cached = 0;
|
||||
if ($cached > 0) {
|
||||
return $cached;
|
||||
}
|
||||
$fromCfg = (int) cfg('rbac.default_company_id', 0);
|
||||
if ($fromCfg > 0) {
|
||||
$cached = $fromCfg;
|
||||
return $cached;
|
||||
}
|
||||
$slug = (string) cfg('rbac.default_company_slug', 'default');
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare('SELECT id FROM companies WHERE slug = ? LIMIT 1');
|
||||
$stmt->execute([$slug]);
|
||||
$id = (int) $stmt->fetchColumn();
|
||||
if ($id > 0) {
|
||||
$cached = $id;
|
||||
return $cached;
|
||||
}
|
||||
$cached = 1;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append company scope to WHERE fragments.
|
||||
*
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
*/
|
||||
public static function appendCompanyScope(string $column, array &$where, array &$params, ?array $user = null): void {
|
||||
$allowed = self::allowedCompanyIds($user);
|
||||
if ($allowed === null) {
|
||||
return;
|
||||
}
|
||||
if ($allowed === []) {
|
||||
$where[] = '0=1';
|
||||
return;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
|
||||
$where[] = "$column IN ($placeholders)";
|
||||
foreach ($allowed as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
/** Match fingerprint counts within the same company. */
|
||||
public static function fingerprintCountSql(string $rAlias = 'r', string $r2Alias = 'r2'): string {
|
||||
return " AND $r2Alias.company_id = $rAlias.company_id";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,slug:string,name:string,role:string}>
|
||||
*/
|
||||
public static function fetchMemberships(int $userId): array {
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT c.id, c.slug, c.name, m.role
|
||||
FROM company_memberships m
|
||||
INNER JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY c.name ASC'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'name' => (string) $row['name'],
|
||||
'role' => (string) $row['role'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:int,slug:string,name:string,role:string}> $memberships
|
||||
*/
|
||||
public static function pickActiveCompanyId(array $memberships): int {
|
||||
if ($memberships === []) {
|
||||
return self::defaultCompanyId();
|
||||
}
|
||||
return (int) $memberships[0]['id'];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> session user payload */
|
||||
public static function buildSessionUser(array $dbUser): array {
|
||||
$uid = (int) $dbUser['id'];
|
||||
$memberships = self::fetchMemberships($uid);
|
||||
return [
|
||||
'id' => $uid,
|
||||
'username' => (string) $dbUser['username'],
|
||||
'role' => self::normalizeGlobalRole((string) ($dbUser['role'] ?? self::GLOBAL_VIEWER)),
|
||||
'companies' => $memberships,
|
||||
'active_company_id' => self::pickActiveCompanyId($memberships),
|
||||
];
|
||||
}
|
||||
|
||||
public static function seedMembershipsForUser(int $userId, string $globalRole): void {
|
||||
$pdo = Database::pdo();
|
||||
$companyId = self::defaultCompanyId();
|
||||
$globalRole = self::normalizeGlobalRole($globalRole);
|
||||
$companyRole = self::COMPANY_MEMBER;
|
||||
if (in_array($globalRole, self::GLOBAL_ADMIN_ROLES, true)) {
|
||||
$companyRole = self::COMPANY_OWNER;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$sql = 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
|
||||
} else {
|
||||
$sql = 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$userId, $companyId, $companyRole]);
|
||||
}
|
||||
|
||||
private static function membershipRoleForUser(array $user, int $companyId): ?string {
|
||||
foreach ($user['companies'] ?? [] as $c) {
|
||||
if ((int) ($c['id'] ?? 0) === $companyId) {
|
||||
return (string) ($c['role'] ?? '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function privilegeSetKeys(): array {
|
||||
return array_keys(self::PRIVILEGE_SETS);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function grantsForPrivilegeSet(string $setKey): array {
|
||||
$setKey = trim($setKey);
|
||||
if ($setKey === '' || $setKey === self::PRIVILEGE_SET_NONE) {
|
||||
return [];
|
||||
}
|
||||
return self::PRIVILEGE_SETS[$setKey] ?? [];
|
||||
}
|
||||
|
||||
public static function encodePermissionsJson(?string $privilegeSetKey): ?string {
|
||||
$grants = self::grantsForPrivilegeSet((string) $privilegeSetKey);
|
||||
if ($grants === []) {
|
||||
return null;
|
||||
}
|
||||
return json_encode(['grants' => $grants], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function privilegeSetFromPermissionsJson(?string $raw): string {
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return self::PRIVILEGE_SET_NONE;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return self::PRIVILEGE_SET_NONE;
|
||||
}
|
||||
$grants = $decoded['grants'] ?? $decoded;
|
||||
if (!is_array($grants)) {
|
||||
return self::PRIVILEGE_SET_NONE;
|
||||
}
|
||||
sort($grants);
|
||||
foreach (self::PRIVILEGE_SETS as $key => $want) {
|
||||
$sorted = $want;
|
||||
sort($sorted);
|
||||
if ($grants === $sorted) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
public static function isRoot(?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
return self::normalizeGlobalRole((string) ($user['role'] ?? '')) === self::GLOBAL_ROOT;
|
||||
}
|
||||
|
||||
public static function canManageRbac(?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
if (self::isGlobalAdmin($user)) {
|
||||
return true;
|
||||
}
|
||||
foreach ($user['companies'] ?? [] as $c) {
|
||||
$role = (string) ($c['role'] ?? '');
|
||||
if ($role === self::COMPANY_OWNER || $role === self::COMPANY_ADMIN) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT permissions_json FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([(int) $user['id'], $companyId]);
|
||||
$raw = $stmt->fetchColumn();
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return false;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
$grants = $decoded['grants'] ?? $decoded;
|
||||
if (!is_array($grants)) {
|
||||
return false;
|
||||
}
|
||||
return in_array($action, $grants, true);
|
||||
}
|
||||
}
|
||||
252
src/RbacAdminRepository.php
Normal file
252
src/RbacAdminRepository.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** RBAC admin: global roles, company memberships, privilege sets. */
|
||||
final class RbacAdminRepository {
|
||||
private function __construct() {}
|
||||
|
||||
public static function buildPanel(?array $actor = null): array {
|
||||
$actor ??= Auth::user();
|
||||
if (!$actor || !Rbac::canManageRbac($actor)) {
|
||||
return ['ok' => false, 'error' => 'forbidden'];
|
||||
}
|
||||
$actorId = (int) ($actor['id'] ?? 0);
|
||||
$globalAdmin = Rbac::isGlobalAdmin($actor);
|
||||
$companies = self::listCompaniesForActor($actor);
|
||||
$companyIds = array_map(static fn($c) => (int) $c['id'], $companies);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'actor' => [
|
||||
'id' => $actorId,
|
||||
'username' => (string) ($actor['username'] ?? ''),
|
||||
'role' => (string) ($actor['role'] ?? ''),
|
||||
'is_root' => Rbac::isRoot($actor),
|
||||
'is_global_admin' => $globalAdmin,
|
||||
],
|
||||
'companies' => $companies,
|
||||
'users' => self::listUsersWithMemberships($companyIds, $globalAdmin),
|
||||
'global_roles' => [
|
||||
Rbac::GLOBAL_ROOT,
|
||||
Rbac::GLOBAL_PLATFORM_ADMIN,
|
||||
Rbac::GLOBAL_VIEWER,
|
||||
],
|
||||
'company_roles' => [
|
||||
Rbac::COMPANY_OWNER,
|
||||
Rbac::COMPANY_ADMIN,
|
||||
Rbac::COMPANY_MEMBER,
|
||||
],
|
||||
'privilege_sets' => array_map(
|
||||
static fn($key) => [
|
||||
'key' => $key,
|
||||
'label' => str_replace('_', ' ', $key),
|
||||
'grants' => Rbac::grantsForPrivilegeSet($key),
|
||||
],
|
||||
Rbac::privilegeSetKeys()
|
||||
),
|
||||
'permissions' => [
|
||||
'can_edit_global_roles' => Rbac::isRoot($actor),
|
||||
'can_manage_platform' => Rbac::can('manage_platform', null, $actor),
|
||||
'can_manage_company_users' => $globalAdmin,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function setGlobalRole(int $targetUserId, string $role, ?array $actor = null): array {
|
||||
$actor ??= Auth::user();
|
||||
if (!$actor || !Rbac::isRoot($actor)) {
|
||||
return ['ok' => false, 'error' => 'forbidden'];
|
||||
}
|
||||
$role = Rbac::normalizeGlobalRole($role);
|
||||
$allowed = [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN, Rbac::GLOBAL_VIEWER];
|
||||
if (!in_array($role, $allowed, true)) {
|
||||
return ['ok' => false, 'error' => 'invalid_role'];
|
||||
}
|
||||
if ($targetUserId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_user'];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
|
||||
$stmt->execute([$role, $targetUserId]);
|
||||
if ($stmt->rowCount() < 1) {
|
||||
return ['ok' => false, 'error' => 'user_not_found'];
|
||||
}
|
||||
if (in_array($role, [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN], true)) {
|
||||
Rbac::seedMembershipsForUser($targetUserId, $role);
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function setCompanyRole(int $targetUserId, int $companyId, string $role, ?array $actor = null): array {
|
||||
$actor ??= Auth::user();
|
||||
if (!$actor || !self::canEditMembership($actor, $companyId)) {
|
||||
return ['ok' => false, 'error' => 'forbidden'];
|
||||
}
|
||||
$allowed = [Rbac::COMPANY_OWNER, Rbac::COMPANY_ADMIN, Rbac::COMPANY_MEMBER];
|
||||
if (!in_array($role, $allowed, true)) {
|
||||
return ['ok' => false, 'error' => 'invalid_role'];
|
||||
}
|
||||
if ($targetUserId <= 0 || $companyId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_input'];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
if (Database::isMysql()) {
|
||||
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE role = VALUES(role)';
|
||||
} else {
|
||||
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, company_id) DO UPDATE SET role = excluded.role';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$targetUserId, $companyId, $role]);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public static function applyPrivilegeSet(int $targetUserId, int $companyId, string $setKey, ?array $actor = null): array {
|
||||
$actor ??= Auth::user();
|
||||
if (!$actor || !self::canEditMembership($actor, $companyId)) {
|
||||
return ['ok' => false, 'error' => 'forbidden'];
|
||||
}
|
||||
if ($targetUserId <= 0 || $companyId <= 0) {
|
||||
return ['ok' => false, 'error' => 'invalid_input'];
|
||||
}
|
||||
$setKey = trim($setKey);
|
||||
if ($setKey !== '' && $setKey !== Rbac::PRIVILEGE_SET_NONE && !isset(Rbac::PRIVILEGE_SETS[$setKey])) {
|
||||
return ['ok' => false, 'error' => 'invalid_privilege_set'];
|
||||
}
|
||||
$json = Rbac::encodePermissionsJson($setKey === Rbac::PRIVILEGE_SET_NONE ? '' : $setKey);
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE company_memberships SET permissions_json = ? WHERE user_id = ? AND company_id = ?'
|
||||
);
|
||||
$stmt->execute([$json, $targetUserId, $companyId]);
|
||||
if ($stmt->rowCount() < 1) {
|
||||
return ['ok' => false, 'error' => 'membership_not_found'];
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,slug:string,name:string}> */
|
||||
private static function listCompaniesForActor(array $actor): array {
|
||||
$pdo = Database::pdo();
|
||||
$allowed = Rbac::allowedCompanyIds($actor);
|
||||
if ($allowed === null) {
|
||||
$stmt = $pdo->query('SELECT id, slug, name FROM companies ORDER BY name ASC');
|
||||
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
if ($allowed === []) {
|
||||
return [];
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($allowed), '?'));
|
||||
$stmt = $pdo->prepare("SELECT id, slug, name FROM companies WHERE id IN ($ph) ORDER BY name ASC");
|
||||
$stmt->execute($allowed);
|
||||
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @return list<array{id:int,slug:string,name:string}> */
|
||||
private static function mapCompanies(array $rows): array {
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'name' => (string) $row['name'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $companyIds
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function listUsersWithMemberships(array $companyIds, bool $globalAdmin): array {
|
||||
$pdo = Database::pdo();
|
||||
if ($globalAdmin) {
|
||||
$users = $pdo->query('SELECT id, username, role FROM users ORDER BY username ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
if ($companyIds === []) {
|
||||
return [];
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($companyIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT u.id, u.username, u.role
|
||||
FROM users u
|
||||
INNER JOIN company_memberships m ON m.user_id = u.id
|
||||
WHERE m.company_id IN ($ph)
|
||||
ORDER BY u.username ASC"
|
||||
);
|
||||
$stmt->execute($companyIds);
|
||||
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($users as $u) {
|
||||
$uid = (int) $u['id'];
|
||||
$memberships = self::membershipsForUser($uid, $companyIds, $globalAdmin);
|
||||
$out[] = [
|
||||
'id' => $uid,
|
||||
'username' => (string) $u['username'],
|
||||
'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')),
|
||||
'memberships' => $memberships,
|
||||
'auth_failures' => AuthAttempts::recentFailureCount((string) $u['username']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $companyIds
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function membershipsForUser(int $userId, array $companyIds, bool $globalAdmin): array {
|
||||
$pdo = Database::pdo();
|
||||
if ($globalAdmin) {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
|
||||
FROM company_memberships m
|
||||
INNER JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY c.name ASC'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
} else {
|
||||
if ($companyIds === []) {
|
||||
return [];
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($companyIds), '?'));
|
||||
$params = array_merge([$userId], $companyIds);
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
|
||||
FROM company_memberships m
|
||||
INNER JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = ? AND m.company_id IN ($ph)
|
||||
ORDER BY c.name ASC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$raw = isset($row['permissions_json']) ? (string) $row['permissions_json'] : '';
|
||||
$out[] = [
|
||||
'company_id' => (int) $row['company_id'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'name' => (string) $row['name'],
|
||||
'role' => (string) $row['role'],
|
||||
'privilege_set' => Rbac::privilegeSetFromPermissionsJson($raw),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function canEditMembership(array $actor, int $companyId): bool {
|
||||
if (Rbac::isGlobalAdmin($actor)) {
|
||||
return true;
|
||||
}
|
||||
if (!Rbac::canAccessCompany($companyId, $actor)) {
|
||||
return false;
|
||||
}
|
||||
$uid = (int) ($actor['id'] ?? 0);
|
||||
$role = $uid > 0 ? Rbac::companyRole($uid, $companyId) : null;
|
||||
return $role === Rbac::COMPANY_OWNER || $role === Rbac::COMPANY_ADMIN;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user