RelationshipResources.php•15.2 kB
<?php
declare(strict_types=1);
namespace OpenFGA\MCP\Resources;
use OpenFGA\ClientInterface;
use OpenFGA\MCP\Completions\{ModelIdCompletionProvider, ObjectCompletionProvider, RelationCompletionProvider, StoreIdCompletionProvider, UserCompletionProvider};
use OpenFGA\Models\Collections\UsersListInterface;
use OpenFGA\Models\{LeafInterface, NodeInterface, TupleKey, UsersetTreeInterface};
use OpenFGA\Responses\{CheckResponseInterface, ExpandResponseInterface, ReadTuplesResponseInterface};
use PhpMcp\Server\Attributes\{CompletionProvider, McpResourceTemplate};
use Throwable;
use function array_unique;
use function assert;
use function count;
use function in_array;
final readonly class RelationshipResources extends AbstractResources
{
public function __construct(
private ClientInterface $client,
) {
}
/**
* Check if a user has a specific permission on an object.
*
* @param string $storeId The ID of the store
* @param string $user The user to check (e.g., "user:123")
* @param string $relation The relation to check (e.g., "reader")
* @param string $object The object to check (e.g., "document:456")
* @param string $modelId The authorization model ID (defaults to 'latest')
*
* @throws Throwable
*
* @return array<string, mixed> Permission check result
*/
#[McpResourceTemplate(
uriTemplate: 'openfga://store/{storeId}/check?user={user}&relation={relation}&object={object}&model={modelId}',
name: 'check_permission',
description: 'Check if a user has a specific permission on an object',
mimeType: 'application/json',
)]
public function checkPermission(
#[CompletionProvider(provider: StoreIdCompletionProvider::class)]
string $storeId,
#[CompletionProvider(provider: UserCompletionProvider::class)]
string $user,
#[CompletionProvider(provider: RelationCompletionProvider::class)]
string $relation,
#[CompletionProvider(provider: ObjectCompletionProvider::class)]
string $object,
#[CompletionProvider(provider: ModelIdCompletionProvider::class)]
string $modelId = 'latest',
): array {
$error = $this->checkOfflineMode('Checking permission');
if (null !== $error) {
return $error;
}
$failure = null;
$result = [];
$called = false;
$tuple = new TupleKey(
user: $user,
relation: $relation,
object: $object,
);
$this->client->check(
store: $storeId,
model: $modelId,
tuple: $tuple,
)
->failure(static function (Throwable $e) use (&$failure, &$called): void {
$called = true;
$failure = ['error' => '❌ Failed to check permission! Error: ' . $e->getMessage()];
})
->success(static function (mixed $response) use (&$result, $user, $relation, $object, &$called): void {
$called = true;
assert($response instanceof CheckResponseInterface);
$result = [
'allowed' => $response->getAllowed(),
'user' => $user,
'relation' => $relation,
'object' => $object,
'resolution' => $response->getResolution(),
];
});
// If neither callback was called, it means the promise chain wasn't resolved
if (! $called) {
return [
'error' => '❌ Promise was not resolved',
];
}
return $failure ?? $result;
}
/**
* Expand all users who have a specific relation to an object.
*
* @param string $storeId The ID of the store
* @param string $object The object to expand (e.g., "document:456")
* @param string $relation The relation to expand (e.g., "reader")
*
* @throws Throwable
*
* @return array<string, mixed> Expanded relationships
*/
#[McpResourceTemplate(
uriTemplate: 'openfga://store/{storeId}/expand?object={object}&relation={relation}',
name: 'expand_relationship',
description: 'Expand all users who have a specific relation to an object',
mimeType: 'application/json',
)]
public function expandRelationships(
#[CompletionProvider(provider: StoreIdCompletionProvider::class)]
string $storeId,
#[CompletionProvider(provider: ObjectCompletionProvider::class)]
string $object,
#[CompletionProvider(provider: RelationCompletionProvider::class)]
string $relation,
): array {
$error = $this->checkOfflineMode('Expanding relationships');
if (null !== $error) {
return $error;
}
$failure = null;
$result = [];
$called = false;
$tuple = new TupleKey(
user: '*',
relation: $relation,
object: $object,
);
$this->client->expand(
store: $storeId,
tuple: $tuple,
)
->failure(static function (Throwable $e) use (&$failure, &$called): void {
$called = true;
$failure = ['error' => '❌ Failed to expand relationships! Error: ' . $e->getMessage()];
})
->success(function (mixed $response) use (&$result, $object, $relation, &$called): void {
$called = true;
assert($response instanceof ExpandResponseInterface);
$tree = $response->getTree();
$users = [];
// Extract users from the expansion tree
if ($tree instanceof UsersetTreeInterface) {
$root = $tree->getRoot();
$extractedUsers = $this->extractUsersFromNode($root);
$users = array_unique($extractedUsers);
}
$result = [
'object' => $object,
'relation' => $relation,
'users' => $users,
'count' => count($users),
];
});
// If neither callback was called, it means the promise chain wasn't resolved
if (! $called) {
return [
'object' => $object,
'relation' => $relation,
'users' => [],
'count' => 0,
];
}
return $failure ?? $result;
}
/**
* List all objects in a specific OpenFGA store.
*
* @param string $storeId The ID of the store
*
* @throws Throwable
*
* @return array<string, mixed> List of objects
*/
#[McpResourceTemplate(
uriTemplate: 'openfga://store/{storeId}/objects',
name: 'list_objects',
description: 'List all objects in a specific OpenFGA store',
mimeType: 'application/json',
)]
public function listObjects(
#[CompletionProvider(provider: StoreIdCompletionProvider::class)]
string $storeId,
): array {
$error = $this->checkOfflineMode('Listing objects');
if (null !== $error) {
return $error;
}
$failure = null;
$objects = [];
$continuationToken = null;
$called = false;
do {
$hasMore = false;
$pageSize = 100;
// Read tuples with wildcard filters (empty strings act as wildcards)
$tuple = new TupleKey(
user: '',
relation: '',
object: '',
);
$this->client->readTuples(
store: $storeId,
tuple: $tuple,
continuationToken: $continuationToken,
pageSize: $pageSize,
)
->failure(static function (Throwable $e) use (&$failure, &$called): void {
$called = true;
$failure = ['error' => '❌ Failed to read tuples! Error: ' . $e->getMessage()];
})
->success(static function (mixed $response) use (&$objects, &$continuationToken, &$hasMore, &$called): void {
$called = true;
assert($response instanceof ReadTuplesResponseInterface);
$tuples = $response->getTuples();
foreach ($tuples as $tuple) {
$object = $tuple->getKey()->getObject();
if (! in_array($object, $objects, true)) {
$objects[] = $object;
}
}
$continuationToken = $response->getContinuationToken();
$hasMore = null !== $continuationToken && '' !== $continuationToken;
});
if (null !== $failure) {
break;
}
} while ($hasMore && $called);
return $failure ?? [
'store_id' => $storeId,
'objects' => $objects,
'count' => count($objects),
];
}
/**
* List all relationships (tuples) in a specific OpenFGA store.
*
* @param string $storeId The ID of the store
*
* @throws Throwable
*
* @return array<string, mixed> List of relationships
*/
#[McpResourceTemplate(
uriTemplate: 'openfga://store/{storeId}/relationships',
name: 'list_relationships',
description: 'List all relationships (tuples) in a specific OpenFGA store',
mimeType: 'application/json',
)]
public function listRelationships(
#[CompletionProvider(provider: StoreIdCompletionProvider::class)]
string $storeId,
): array {
$error = $this->checkOfflineMode('Listing relationships');
if (null !== $error) {
return $error;
}
$failure = null;
$relationships = [];
$continuationToken = null;
$called = false;
do {
$hasMore = false;
$pageSize = 100;
// Read tuples with wildcard filters (empty strings act as wildcards)
$tuple = new TupleKey(
user: '',
relation: '',
object: '',
);
$this->client->readTuples(
store: $storeId,
tuple: $tuple,
continuationToken: $continuationToken,
pageSize: $pageSize,
)
->failure(static function (Throwable $e) use (&$failure, &$called): void {
$called = true;
$failure = ['error' => '❌ Failed to read tuples! Error: ' . $e->getMessage()];
})
->success(static function (mixed $response) use (&$relationships, &$continuationToken, &$hasMore, &$called): void {
$called = true;
assert($response instanceof ReadTuplesResponseInterface);
$tuples = $response->getTuples();
foreach ($tuples as $tuple) {
$key = $tuple->getKey();
$relationships[] = [
'user' => $key->getUser(),
'relation' => $key->getRelation(),
'object' => $key->getObject(),
];
}
$continuationToken = $response->getContinuationToken();
$hasMore = null !== $continuationToken && '' !== $continuationToken;
});
if (null !== $failure) {
break;
}
} while ($hasMore && $called);
return $failure ?? [
'store_id' => $storeId,
'relationships' => $relationships,
'count' => count($relationships),
];
}
/**
* List all users in a specific OpenFGA store.
*
* @param string $storeId The ID of the store
*
* @throws Throwable
*
* @return array<string, mixed> List of users
*/
#[McpResourceTemplate(
uriTemplate: 'openfga://store/{storeId}/users',
name: 'list_users',
description: 'List all users in a specific OpenFGA store',
mimeType: 'application/json',
)]
public function listUsers(
#[CompletionProvider(provider: StoreIdCompletionProvider::class)]
string $storeId,
): array {
$error = $this->checkOfflineMode('Listing users');
if (null !== $error) {
return $error;
}
$failure = null;
$users = [];
$continuationToken = null;
$called = false;
do {
$hasMore = false;
$pageSize = 100;
// Read tuples with wildcard filters (empty strings act as wildcards)
$tuple = new TupleKey(
user: '',
relation: '',
object: '',
);
$this->client->readTuples(
store: $storeId,
tuple: $tuple,
continuationToken: $continuationToken,
pageSize: $pageSize,
)
->failure(static function (Throwable $e) use (&$failure, &$called): void {
$called = true;
$failure = ['error' => '❌ Failed to read tuples! Error: ' . $e->getMessage()];
})
->success(static function (mixed $response) use (&$users, &$continuationToken, &$hasMore, &$called): void {
$called = true;
assert($response instanceof ReadTuplesResponseInterface);
$tuples = $response->getTuples();
foreach ($tuples as $tuple) {
$user = $tuple->getKey()->getUser();
if (! in_array($user, $users, true)) {
$users[] = $user;
}
}
$continuationToken = $response->getContinuationToken();
$hasMore = null !== $continuationToken && '' !== $continuationToken;
});
if (null !== $failure) {
break;
}
} while ($hasMore && $called);
return $failure ?? [
'store_id' => $storeId,
'users' => $users,
'count' => count($users),
];
}
/**
* Extract users from a node in the expansion tree.
*
* @param NodeInterface $node The tree node to process
* @return array<string> List of users
*/
private function extractUsersFromNode(NodeInterface $node): array
{
$users = [];
// Handle leaf nodes
$leaf = $node->getLeaf();
if ($leaf instanceof LeafInterface) {
$usersList = $leaf->getUsers();
if ($usersList instanceof UsersListInterface) {
foreach ($usersList as $userList) {
$users[] = $userList->getUser();
}
}
}
return $users;
}
}