Skip to content
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ parameters:
- src
- test
level: 9
phpVersion: 80200
inferPrivatePropertyTypeFromConstructor: true
checkGenericClassInNonGenericObjectType: true
189 changes: 189 additions & 0 deletions src/Plugin/Auth/GrantRevokeHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php declare(strict_types=1);

/*
Copyright (c) 2023-present, Manticore Software LTD (https://manticoresearch.com)

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2 or any later
version. You should have received a copy of the GPL license along with this
program; if you did not, you can find it at http://www.gnu.org/
*/

namespace Manticoresearch\Buddy\Base\Plugin\Auth;

use Manticoresearch\Buddy\Core\Error\GenericError;
use Manticoresearch\Buddy\Core\ManticoreSearch\Response;
use Manticoresearch\Buddy\Core\Plugin\BaseHandlerWithClient;
use Manticoresearch\Buddy\Core\Task\Task;
use Manticoresearch\Buddy\Core\Task\TaskResult;

/**
* Handles GRANT and REVOKE commands for authentication plugin
*/
final class GrantRevokeHandler extends BaseHandlerWithClient {
/**
* Initialize the executor
*
* @param Payload $payload The payload containing permission data
*/
public function __construct(public Payload $payload) {
}

/**
* Process the request asynchronously
*
* @return Task
*/
public function run(): Task {
return Task::create(
fn () => $this->processRequest(),
[$this->payload, $this->manticoreClient]
)->run();
}

/**
* Process the GRANT or REVOKE request
*
* @return TaskResult
* @throws GenericError
*/
private function processRequest(): TaskResult {
assert($this->payload->username !== null);
assert($this->payload->action !== null);
assert($this->payload->target !== null);
$username = addslashes($this->payload->username);
$action = addslashes($this->payload->action);
$target = addslashes($this->payload->target);
$budget = $this->payload->budget !== null ? addslashes($this->payload->budget) : '{}';

if ($this->payload->type === 'grant') {
return $this->handleGrant($username, $action, $target, $budget);
}

if ($this->payload->type === 'revoke') {
return $this->handleRevoke($username, $action, $target);
}

throw GenericError::create('Invalid operation type for GrantRevokeHandler.');
}

/**
* Check if the user exists in the users table
*
* @param string $username The username to check
* @return bool
* @throws GenericError
*/
private function userExists(string $username): bool {
$tableUsers = Payload::AUTH_USERS_TABLE;
$query = "SELECT count(*) as c FROM {$tableUsers} WHERE username = '{$username}'";
/** @var Response $resp */
$resp = $this->manticoreClient->sendRequest($query);

if ($resp->hasError()) {
throw GenericError::create((string)$resp->getError());
}

$result = $resp->getResult()->toArray();
if (!is_array($result) || !isset($result[0]['data'][0]['c'])) {
throw GenericError::create('Unexpected response format when checking user existence.');
}

$count = (int)$result[0]['data'][0]['c'];
return $count > 0;
}

/**
* Check if a permission already exists for the user
*
* @param string $username The username to check
* @param string $action The action to check
* @param string $target The target to check
* @return bool
* @throws GenericError
*/
private function permissionExists(string $username, string $action, string $target): bool {
$tablePerms = Payload::AUTH_PERMISSIONS_TABLE;
$query = "SELECT count(*) as c FROM {$tablePerms} WHERE ".
"username = '{$username}' AND action = '{$action}' AND target = '{$target}'";
/** @var Response $resp */
$resp = $this->manticoreClient->sendRequest($query);

if ($resp->hasError()) {
throw GenericError::create((string)$resp->getError());
}

$result = $resp->getResult()->toArray();
if (!is_array($result) || !isset($result[0]['data'][0]['c'])) {
throw GenericError::create('Unexpected response format when checking permission existence.');
}

$count = (int)$result[0]['data'][0]['c'];
return $count > 0;
}

/**
* Handle GRANT command by adding a permission
*
* @param string $username The username to grant permissions to
* @param string $action The action to grant (e.g., read, write)
* @param string $target The target (e.g., *, table/mytable)
* @param string $budget JSON-encoded budget or '{}'
* @return TaskResult
* @throws GenericError
*/
private function handleGrant(string $username, string $action, string $target, string $budget): TaskResult {
if (!$this->userExists($username)) {
throw GenericError::create("User '{$username}' does not exist.");
}

// Check if permission already exists
if ($this->permissionExists($username, $action, $target)) {
throw GenericError::create("User '{$username}' already has '{$action}' permission on '{$target}'.");
}

$tablePerms = Payload::AUTH_PERMISSIONS_TABLE;
$query = "INSERT INTO {$tablePerms} (username, action, target, allow, budget) " .
"VALUES ('{$username}', '{$action}', '{$target}', 1, '{$budget}')";
/** @var Response $resp */
$resp = $this->manticoreClient->sendRequest($query);

if ($resp->hasError()) {
throw GenericError::create((string)$resp->getError());
}

return TaskResult::none();
}

/**
* Handle REVOKE command by removing a permission
*
* @param string $username The username to revoke permissions from
* @param string $action The action to revoke (e.g., read, write)
* @param string $target The target (e.g., *, table/mytable)
* @return TaskResult
* @throws GenericError
*/
private function handleRevoke(string $username, string $action, string $target): TaskResult {
if (!$this->userExists($username)) {
throw GenericError::create("User '{$username}' does not exist.");
}

// Check if permission exists before revoking
if (!$this->permissionExists($username, $action, $target)) {
throw GenericError::create("User '{$username}' does not have '{$action}' permission on '{$target}'.");
}

$tablePerms = Payload::AUTH_PERMISSIONS_TABLE;
$query = "DELETE FROM {$tablePerms} WHERE username = '{$username}' ".
"AND action = '{$action}' AND target = '{$target}'";
/** @var Response $resp */
$resp = $this->manticoreClient->sendRequest($query);

if ($resp->hasError()) {
throw GenericError::create((string)$resp->getError());
}

return TaskResult::none();
}
}
101 changes: 101 additions & 0 deletions src/Plugin/Auth/HashGeneratorTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php declare(strict_types=1);

/*
Copyright (c) 2023-present, Manticore Software LTD (https://manticoresearch.com)

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2 or any later
version. You should have received a copy of the GPL license along with this
program; if you did not, you can find it at http://www.gnu.org/
*/
namespace Manticoresearch\Buddy\Base\Plugin\Auth;

use Manticoresearch\Buddy\Core\Error\GenericError;

/**
* Trait for generating password hashes for authentication
*/
trait HashGeneratorTrait {
// Hash key constants
private const PASSWORD_SHA1_KEY = 'password_sha1_no_salt';
private const PASSWORD_SHA256_KEY = 'password_sha256';
private const BEARER_SHA256_KEY = 'bearer_sha256';

/**
* Generate password hashes with token for bearer authentication
*
* @param string $password The password to hash
* @param string $token The token to hash for bearer auth
* @param string $salt The salt to use for hashing
* @return string JSON-encoded hashes
* @throws GenericError
*/
private function generateHashesWithToken(string $password, string $token, string $salt): string {
$hashes = [
self::PASSWORD_SHA1_KEY => sha1($password),
self::PASSWORD_SHA256_KEY => hash('sha256', $salt . $password),
self::BEARER_SHA256_KEY => $this->generateTokenHash($token, $salt),
];
$hashesJson = json_encode($hashes);

if ($hashesJson === false) {
throw GenericError::create('Failed to encode hashes as JSON.');
}

return addslashes($hashesJson);
}

/**
* Update password hashes while preserving existing bearer_sha256
*
* @param string $newPassword The new password to hash
* @param string $salt The salt to use for hashing
* @param array<string, mixed> $existingHashes The existing hashes array to preserve bearer_sha256
* @return string JSON-encoded updated hashes
* @throws GenericError
*/
private function updatePasswordHashes(string $newPassword, string $salt, array $existingHashes): string {
if (empty($existingHashes[self::BEARER_SHA256_KEY])) {
throw GenericError::create('Existing bearer_sha256 hash is required for password update.');
}

$updatedHashes = [
self::PASSWORD_SHA1_KEY => sha1($newPassword),
self::PASSWORD_SHA256_KEY => hash('sha256', $salt . $newPassword),
self::BEARER_SHA256_KEY => $existingHashes[self::BEARER_SHA256_KEY], // Preserve existing token hash
];
$hashesJson = json_encode($updatedHashes);

if ($hashesJson === false) {
throw GenericError::create('Failed to encode updated hashes as JSON.');
}

return addslashes($hashesJson);
}

/**
* Validate hash structure
*
* @param array<string, mixed> $hashes The hashes array to validate
* @throws GenericError
*/
private function validateHashesStructure(array $hashes): void {
$required = [self::PASSWORD_SHA1_KEY, self::PASSWORD_SHA256_KEY, self::BEARER_SHA256_KEY];
foreach ($required as $key) {
if (!isset($hashes[$key]) || !is_string($hashes[$key])) {
throw GenericError::create("Invalid hash structure: missing or invalid '{$key}'.");
}
}
}

/**
* Generate token hash for bearer authentication
*
* @param string $token The token to hash
* @param string $salt The salt to use for hashing
* @return string The token hash
*/
private function generateTokenHash(string $token, string $salt): string {
return hash('sha256', $salt . hash('sha256', $token));
}
}
Loading
Loading