## Summary Adds the missing backend contract used by Pleno Control Plane Conversations/Suggestions to create an employee login action safely. - issues 60–900 second one-time limited-backoffice login grants - persists only SHA-256 bearer digests; bearer recovery is deterministic under the server encryption key for identical idempotent retries - enforces manager permissions, department scope, active managed-employee constraints, one-time atomic exchange, revocation, expiry, and account-deletion cleanup - adds employee-create idempotency so an approved automation retry cannot duplicate an employee - documents the create, revoke, and unauthenticated exchange endpoints in OpenAPI ## Security and concurrency - bearer values are returned only in a URL fragment and are never written to logs or database plaintext - employee and grant rows use a consistent employee-then-grant lock order - deactivation revokes outstanding grants and existing sessions in the same transaction - consumed, revoked, expired, or payload-mismatched idempotent replays fail closed ## Verification - `scripts/php-ci-test.sh api`: 273 passed, 11,086 assertions (one inherited warning) - focused security contract: 1 passed, 21 assertions - PHP syntax checks passed for the service and routes - `git diff --check` passed ## Dependency Required by copenhagentruckwash/pleno-control-plane#1. Merge before the matching frontend and Control Plane PRs.
580 lines
22 KiB
PHP
580 lines
22 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use objects\logs_o;
|
|
use objects\users_o;
|
|
|
|
/**
|
|
* Issues narrowly scoped bearer grants which can be exchanged once for a normal
|
|
* employee session. Only a SHA-256 digest is persisted. The bearer is derived
|
|
* under the server encryption key so the same authorized idempotent request can
|
|
* recover an unconsumed grant after a lost response without storing plaintext.
|
|
*/
|
|
class limited_backoffice_login_grant_service
|
|
{
|
|
public const PURPOSE_EMPLOYEE_DIRECT_LOGIN = 'limited_backoffice_employee_login';
|
|
public const DEFAULT_TTL_SECONDS = 300;
|
|
public const MIN_TTL_SECONDS = 60;
|
|
public const MAX_TTL_SECONDS = 900;
|
|
|
|
public function __construct()
|
|
{
|
|
limited_backoffice_schema_bootstrap::ensureTables();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function create(users_o $manager, int $employeeId, array $payload): array
|
|
{
|
|
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
|
|
|
$purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN));
|
|
if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) {
|
|
throw new limited_backoffice_exception('Unsupported login grant purpose.', 400);
|
|
}
|
|
|
|
$ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS);
|
|
$expiresAt = time() + $ttlSeconds;
|
|
$preflight = ($payload['preflight'] ?? false) === true;
|
|
|
|
$base = [
|
|
'employee_id' => $employeeId,
|
|
'purpose' => $purpose,
|
|
'ttl_seconds' => $ttlSeconds,
|
|
'expires_at' => gmdate('c', $expiresAt),
|
|
'one_time' => true,
|
|
];
|
|
if ($preflight) {
|
|
return $base + ['preflight' => true];
|
|
}
|
|
|
|
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
|
|
if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) {
|
|
throw new limited_backoffice_exception(
|
|
'idempotency_key must contain between 16 and 128 characters.',
|
|
400
|
|
);
|
|
}
|
|
$idempotencyKeyHash = hash('sha256', $idempotencyKey);
|
|
|
|
$grantId = bin2hex(random_bytes(16));
|
|
$bearer = $this->bearerForIdempotency(
|
|
(int)$manager->id,
|
|
$employeeId,
|
|
$purpose,
|
|
$ttlSeconds,
|
|
$idempotencyKey
|
|
);
|
|
$secretHash = hash('sha256', $bearer);
|
|
|
|
$mysqli = $this->mysqli();
|
|
for ($attempt = 0; $attempt < 3; $attempt++) {
|
|
$mysqli->begin_transaction();
|
|
try {
|
|
if (!$this->lockActiveManagedEmployee($employeeId)) {
|
|
throw new limited_backoffice_exception(
|
|
'Cannot create a login grant for an inactive employee.',
|
|
409
|
|
);
|
|
}
|
|
// Target-first ordering matches employee update/deletion. Two
|
|
// cross-managing actors can still form a cycle, so deadlock
|
|
// victims are retried below with the same idempotency identity.
|
|
$authorizedActor = $this->lockAuthorizedActor($manager);
|
|
(new limited_backoffice_service())->assertEmployeeLoginTarget(
|
|
$authorizedActor['manager'],
|
|
$employeeId,
|
|
$authorizedActor['group_id']
|
|
);
|
|
|
|
$existing = $this->findIdempotentGrant(
|
|
(int)$manager->id,
|
|
$employeeId,
|
|
$purpose,
|
|
$idempotencyKeyHash
|
|
);
|
|
if ($existing !== null) {
|
|
if ($this->isReplayableGrant($existing, $secretHash)) {
|
|
$mysqli->commit();
|
|
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
|
}
|
|
throw $this->duplicateGrantException($existing);
|
|
}
|
|
|
|
$statement = $mysqli->prepare(
|
|
'INSERT INTO `limited_backoffice_login_grants`
|
|
(`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`,
|
|
`idempotency_key_hash`, `expires_at`)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
if ($statement === false) {
|
|
throw new limited_backoffice_exception('Unable to prepare login grant.', 500);
|
|
}
|
|
$actorUserId = (int)$manager->id;
|
|
$statement->bind_param(
|
|
'ssiissi',
|
|
$grantId,
|
|
$secretHash,
|
|
$employeeId,
|
|
$actorUserId,
|
|
$purpose,
|
|
$idempotencyKeyHash,
|
|
$expiresAt
|
|
);
|
|
try {
|
|
$statement->execute();
|
|
} finally {
|
|
$statement->close();
|
|
}
|
|
$mysqli->commit();
|
|
break;
|
|
} catch (limited_backoffice_exception $exception) {
|
|
$mysqli->rollback();
|
|
throw $exception;
|
|
} catch (\mysqli_sql_exception $exception) {
|
|
$mysqli->rollback();
|
|
$errorCode = (int)$exception->getCode();
|
|
if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) {
|
|
usleep(1000 * ($attempt + 1));
|
|
continue;
|
|
}
|
|
if ($errorCode === 1062) {
|
|
$existing = $this->findIdempotentGrant(
|
|
(int)$manager->id,
|
|
$employeeId,
|
|
$purpose,
|
|
$idempotencyKeyHash
|
|
);
|
|
if ($existing !== null) {
|
|
if ($this->isReplayableGrant($existing, $secretHash)) {
|
|
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
|
}
|
|
throw $this->duplicateGrantException($existing);
|
|
}
|
|
}
|
|
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
|
} catch (\Throwable) {
|
|
$mysqli->rollback();
|
|
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
|
}
|
|
}
|
|
|
|
$this->audit(
|
|
(int)$manager->id,
|
|
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED',
|
|
'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId
|
|
);
|
|
|
|
return $this->grantResult($employeeId, $purpose, $bearer, [
|
|
'grant_id' => $grantId,
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{employee_id:int,token:string}
|
|
*/
|
|
public function exchange(string $bearer): array
|
|
{
|
|
if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) {
|
|
throw $this->invalidGrantException();
|
|
}
|
|
|
|
$mysqli = $this->mysqli();
|
|
$secretHash = hash('sha256', $bearer);
|
|
$mysqli->begin_transaction();
|
|
|
|
try {
|
|
// Resolve the target without locking, then lock employee -> grant. Employee
|
|
// deactivation uses the same order, preventing a direct-login session from
|
|
// surviving a concurrent deactivation and avoiding inverse-order deadlocks.
|
|
$targetLookup = $mysqli->prepare(
|
|
'SELECT `target_user_id`
|
|
FROM `limited_backoffice_login_grants`
|
|
WHERE `secret_hash` = ?
|
|
LIMIT 1'
|
|
);
|
|
if ($targetLookup === false) {
|
|
throw new \RuntimeException('Unable to prepare login grant target lookup.');
|
|
}
|
|
$targetLookup->bind_param('s', $secretHash);
|
|
$targetLookup->execute();
|
|
$target = $targetLookup->get_result()->fetch_assoc() ?: null;
|
|
$targetLookup->close();
|
|
if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) {
|
|
throw $this->invalidGrantException();
|
|
}
|
|
|
|
$statement = $mysqli->prepare(
|
|
'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`,
|
|
`consumed_at`, `revoked_at`
|
|
FROM `limited_backoffice_login_grants`
|
|
WHERE `secret_hash` = ?
|
|
LIMIT 1
|
|
FOR UPDATE'
|
|
);
|
|
if ($statement === false) {
|
|
throw new \RuntimeException('Unable to prepare login grant exchange.');
|
|
}
|
|
$statement->bind_param('s', $secretHash);
|
|
$statement->execute();
|
|
$row = $statement->get_result()->fetch_assoc() ?: null;
|
|
$statement->close();
|
|
|
|
if (
|
|
$row === null
|
|
|| $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN
|
|
|| $row['consumed_at'] !== null
|
|
|| $row['revoked_at'] !== null
|
|
|| (int)$row['expires_at'] <= time()
|
|
|| (int)$row['target_user_id'] !== (int)$target['target_user_id']
|
|
) {
|
|
throw $this->invalidGrantException();
|
|
}
|
|
|
|
$grantRowId = (int)$row['id'];
|
|
$consume = $mysqli->prepare(
|
|
'UPDATE `limited_backoffice_login_grants`
|
|
SET `consumed_at` = UTC_TIMESTAMP()
|
|
WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL
|
|
LIMIT 1'
|
|
);
|
|
if ($consume === false) {
|
|
throw new \RuntimeException('Unable to prepare login grant consumption.');
|
|
}
|
|
$consume->bind_param('i', $grantRowId);
|
|
$consume->execute();
|
|
$affectedRows = $consume->affected_rows;
|
|
$consume->close();
|
|
if ($affectedRows !== 1) {
|
|
throw $this->invalidGrantException();
|
|
}
|
|
|
|
$employeeId = (int)$row['target_user_id'];
|
|
$token = (new authentication())->create_employee_token($employeeId);
|
|
$mysqli->commit();
|
|
} catch (limited_backoffice_exception $exception) {
|
|
$mysqli->rollback();
|
|
throw $exception;
|
|
} catch (\Throwable) {
|
|
$mysqli->rollback();
|
|
throw new limited_backoffice_exception('Unable to exchange login grant.', 500);
|
|
}
|
|
|
|
$this->audit(
|
|
$employeeId,
|
|
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED',
|
|
'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId
|
|
);
|
|
|
|
return ['employee_id' => $employeeId, 'token' => $token];
|
|
}
|
|
|
|
/**
|
|
* @return array{employee_id:int,revoked_count:int}
|
|
*/
|
|
public function revokeForEmployee(users_o $manager, int $employeeId): array
|
|
{
|
|
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
|
|
|
$statement = $this->mysqli()->prepare(
|
|
'UPDATE `limited_backoffice_login_grants`
|
|
SET `revoked_at` = UTC_TIMESTAMP()
|
|
WHERE `target_user_id` = ?
|
|
AND `consumed_at` IS NULL
|
|
AND `revoked_at` IS NULL
|
|
AND `expires_at` >= ?'
|
|
);
|
|
if ($statement === false) {
|
|
throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500);
|
|
}
|
|
$now = time();
|
|
$statement->bind_param('ii', $employeeId, $now);
|
|
$statement->execute();
|
|
$revokedCount = $statement->affected_rows;
|
|
$statement->close();
|
|
|
|
$this->audit(
|
|
(int)$manager->id,
|
|
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED',
|
|
'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId
|
|
);
|
|
|
|
return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount];
|
|
}
|
|
|
|
private function ttlSeconds(mixed $value): int
|
|
{
|
|
if (is_string($value) && ctype_digit($value)) {
|
|
$value = (int)$value;
|
|
}
|
|
if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) {
|
|
throw new limited_backoffice_exception(
|
|
'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.',
|
|
400
|
|
);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function findIdempotentGrant(
|
|
int $actorUserId,
|
|
int $employeeId,
|
|
string $purpose,
|
|
string $idempotencyKeyHash
|
|
): ?array {
|
|
$statement = $this->mysqli()->prepare(
|
|
'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`,
|
|
`consumed_at`, `revoked_at`
|
|
FROM `limited_backoffice_login_grants`
|
|
WHERE `actor_user_id` = ?
|
|
AND `target_user_id` = ?
|
|
AND `purpose` = ?
|
|
AND `idempotency_key_hash` = ?
|
|
LIMIT 1'
|
|
);
|
|
if ($statement === false) {
|
|
throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500);
|
|
}
|
|
$statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash);
|
|
$statement->execute();
|
|
$row = $statement->get_result()->fetch_assoc() ?: null;
|
|
$statement->close();
|
|
return $row;
|
|
}
|
|
|
|
private function bearerForIdempotency(
|
|
int $actorUserId,
|
|
int $employeeId,
|
|
string $purpose,
|
|
int $ttlSeconds,
|
|
string $idempotencyKey
|
|
): string {
|
|
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
|
|
if ($key === '') {
|
|
throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503);
|
|
}
|
|
return 'lbg_' . hash_hmac(
|
|
'sha256',
|
|
$actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey,
|
|
$key
|
|
);
|
|
}
|
|
|
|
private function isReplayableGrant(array $row, string $secretHash): bool
|
|
{
|
|
return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash)
|
|
&& ($row['consumed_at'] ?? null) === null
|
|
&& ($row['revoked_at'] ?? null) === null
|
|
&& (int)($row['expires_at'] ?? 0) > time();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function grantResult(
|
|
int $employeeId,
|
|
string $purpose,
|
|
string $bearer,
|
|
array $row
|
|
): array {
|
|
$expiresAt = (int)$row['expires_at'];
|
|
return [
|
|
'employee_id' => $employeeId,
|
|
'purpose' => $purpose,
|
|
'ttl_seconds' => max(0, $expiresAt - time()),
|
|
'expires_at' => gmdate('c', $expiresAt),
|
|
'one_time' => true,
|
|
'preflight' => false,
|
|
'grant_id' => (string)$row['grant_id'],
|
|
// The fragment avoids ingress request logs and Referer propagation.
|
|
'login_path' => '/login/qr#grant=' . rawurlencode($bearer),
|
|
'exchange_path' => '/auth/limited-backoffice-login-grants/exchange',
|
|
];
|
|
}
|
|
|
|
private function duplicateGrantException(array $row): limited_backoffice_exception
|
|
{
|
|
return new limited_backoffice_exception(
|
|
'A login grant already exists for this idempotency key.',
|
|
409,
|
|
[
|
|
'message' => 'A login grant already exists for this idempotency key.',
|
|
'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT',
|
|
'grant_id' => (string)$row['grant_id'],
|
|
'employee_id' => (int)$row['target_user_id'],
|
|
'purpose' => (string)$row['purpose'],
|
|
'expires_at' => gmdate('c', (int)$row['expires_at']),
|
|
'consumed' => $row['consumed_at'] !== null,
|
|
'revoked' => $row['revoked_at'] !== null,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function invalidGrantException(): limited_backoffice_exception
|
|
{
|
|
return new limited_backoffice_exception('Invalid or expired login grant.', 401);
|
|
}
|
|
|
|
private function lockActiveManagedEmployee(int $employeeId): bool
|
|
{
|
|
$statement = $this->mysqli()->prepare(
|
|
'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id`
|
|
FROM `limited_backoffice_employees` lbe
|
|
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
|
|
WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL
|
|
LIMIT 1
|
|
FOR UPDATE'
|
|
);
|
|
if ($statement === false) {
|
|
throw new limited_backoffice_exception('Unable to lock login grant employee.', 500);
|
|
}
|
|
$statement->bind_param('i', $employeeId);
|
|
$statement->execute();
|
|
$employee = $statement->get_result()->fetch_assoc() ?: null;
|
|
$statement->close();
|
|
if ($employee === null) {
|
|
return false;
|
|
}
|
|
|
|
$groupId = (int)$employee['group_id'];
|
|
$managedGroupId = (int)$employee['managed_group_id'];
|
|
if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) {
|
|
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
|
}
|
|
|
|
// Lock the complete permission range so role changes cannot add elevated
|
|
// capabilities between validation and token creation.
|
|
$permissions = $this->mysqli()->prepare(
|
|
'SELECT `permission`
|
|
FROM `groups_permissions`
|
|
WHERE `group_id` = ?
|
|
FOR UPDATE'
|
|
);
|
|
if ($permissions === false) {
|
|
throw new limited_backoffice_exception('Unable to validate login grant role.', 500);
|
|
}
|
|
$permissions->bind_param('i', $groupId);
|
|
$permissions->execute();
|
|
$result = $permissions->get_result();
|
|
while ($row = $result->fetch_assoc()) {
|
|
if ((string)($row['permission'] ?? '') === 'superuser') {
|
|
$permissions->close();
|
|
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
|
}
|
|
}
|
|
$permissions->close();
|
|
|
|
$groupUsers = $this->mysqli()->prepare(
|
|
'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE'
|
|
);
|
|
if ($groupUsers === false) {
|
|
throw new limited_backoffice_exception('Unable to validate login grant group.', 500);
|
|
}
|
|
$groupUsers->bind_param('i', $groupId);
|
|
$groupUsers->execute();
|
|
$groupUserResult = $groupUsers->get_result();
|
|
$userCount = 0;
|
|
while ($groupUserResult->fetch_assoc() !== null) {
|
|
$userCount++;
|
|
}
|
|
$groupUsers->close();
|
|
if ($userCount !== 1) {
|
|
throw new limited_backoffice_exception('Login grant target group is shared.', 403);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return array{manager:users_o,group_id:int}
|
|
*/
|
|
private function lockAuthorizedActor(users_o $manager): array
|
|
{
|
|
$actorUserId = (int)$manager->id;
|
|
$statement = $this->mysqli()->prepare(
|
|
'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE'
|
|
);
|
|
if ($statement === false) {
|
|
throw new limited_backoffice_exception('Unable to lock login grant actor.', 500);
|
|
}
|
|
$statement->bind_param('i', $actorUserId);
|
|
$statement->execute();
|
|
$actor = $statement->get_result()->fetch_assoc() ?: null;
|
|
$statement->close();
|
|
if (
|
|
$actor === null
|
|
|| (int)$actor['group_id'] <= 0
|
|
|| account_deletion_service::principalIsBlocked('customer', $actorUserId)
|
|
) {
|
|
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
|
}
|
|
|
|
$groupId = (int)$actor['group_id'];
|
|
if ($groupId !== 1) {
|
|
$requiredPermissions = [
|
|
limited_backoffice_service::PERMISSION_ACCESS,
|
|
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
|
|
];
|
|
// Lock the actor's complete permission set, including every department_access_* row
|
|
// consumed by assertEmployeeLoginTarget.
|
|
// The group_id range lock prevents concurrent role replacement
|
|
// from revoking scope between authorization and grant insertion.
|
|
$permissions = $this->mysqli()->prepare(
|
|
'SELECT `permission`
|
|
FROM `groups_permissions`
|
|
WHERE `group_id` = ?
|
|
FOR UPDATE'
|
|
);
|
|
if ($permissions === false) {
|
|
throw new limited_backoffice_exception('Unable to validate login grant actor.', 500);
|
|
}
|
|
$permissions->bind_param('i', $groupId);
|
|
$permissions->execute();
|
|
$result = $permissions->get_result();
|
|
$granted = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$granted[] = (string)$row['permission'];
|
|
}
|
|
$permissions->close();
|
|
if (array_diff($requiredPermissions, $granted) !== []) {
|
|
throw new limited_backoffice_exception(
|
|
'Login grant actor is no longer authorized.',
|
|
403
|
|
);
|
|
}
|
|
}
|
|
|
|
$currentManager = (new users_o())->getUserById($actorUserId);
|
|
if (!$currentManager->exists()) {
|
|
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
|
}
|
|
return [
|
|
'manager' => $currentManager,
|
|
'group_id' => $groupId,
|
|
];
|
|
}
|
|
|
|
private function audit(int $actorUserId, string $event, string $message): void
|
|
{
|
|
try {
|
|
(new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message);
|
|
} catch (\Throwable) {
|
|
// Audit logging must not expose a bearer or block the grant lifecycle.
|
|
}
|
|
}
|
|
|
|
private function mysqli(): \mysqli
|
|
{
|
|
global $db;
|
|
return $db->conn();
|
|
}
|
|
}
|