1
0
mirror of git://f0xx.org/ac/ac-ms-rbac synced 2026-07-29 00:58:10 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:31 +02:00
commit 362a4f0c8e
8 changed files with 801 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-ms-rbac
Microservice API. Remote: `git://f0xx.org/ac/ac-ms-rbac`

25
composer.json Normal file
View 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/"
]
}
}

View File

@@ -0,0 +1,2 @@
<?php
return ['db' => ['driver' => 'sqlite']];

70
public/api/rbac.php Normal file
View 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
View File

@@ -0,0 +1,2 @@
<?php
// API entry — wire nginx to public/

View 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
View 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
View 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;
}
}