From 4be784944c64effea3a29a170a31dd9e41901147 Mon Sep 17 00:00:00 2001
From: rev3z <backuade@gmail.com>
Date: Tue, 11 Jun 2024 22:00:54 +0800
Subject: [PATCH 1/2] feat: loading model from remote url

---
 config/lauthz.php                  |   4 +-
 src/Contracts/ModelLoader.php      |  17 ++++
 src/EnforcerManager.php            |  10 +--
 src/LauthzServiceProvider.php      |   6 ++
 src/Loaders/FileLoader.php         |  39 ++++++++++
 src/Loaders/ModelLoaderFactory.php |  48 ++++++++++++
 src/Loaders/TextLoader.php         |  40 ++++++++++
 src/Loaders/UrlLoader.php          |  58 ++++++++++++++
 tests/ModelLoaderTest.php          | 121 +++++++++++++++++++++++++++++
 9 files changed, 336 insertions(+), 7 deletions(-)
 create mode 100644 src/Contracts/ModelLoader.php
 create mode 100644 src/Loaders/FileLoader.php
 create mode 100644 src/Loaders/ModelLoaderFactory.php
 create mode 100644 src/Loaders/TextLoader.php
 create mode 100644 src/Loaders/UrlLoader.php
 create mode 100644 tests/ModelLoaderTest.php

diff --git a/config/lauthz.php b/config/lauthz.php
index 0cb16f8..b958495 100644
--- a/config/lauthz.php
+++ b/config/lauthz.php
@@ -11,12 +11,14 @@
         * Casbin model setting.
         */
         'model' => [
-            // Available Settings: "file", "text"
+            // Available Settings: "file", "text", "url"
             'config_type' => 'file',
 
             'config_file_path' => __DIR__ . DIRECTORY_SEPARATOR . 'lauthz-rbac-model.conf',
 
             'config_text' => '',
+
+            'config_url' => ''
         ],
 
         /*
diff --git a/src/Contracts/ModelLoader.php b/src/Contracts/ModelLoader.php
new file mode 100644
index 0000000..fc209c8
--- /dev/null
+++ b/src/Contracts/ModelLoader.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Lauthz\Contracts;
+
+
+use Casbin\Model\Model;
+
+interface  ModelLoader
+{
+    /**
+     * Loads model definitions into the provided model object.
+     *
+     * @param Model $model
+     * @return void
+     */
+    function loadModel(Model $model): void;
+}
\ No newline at end of file
diff --git a/src/EnforcerManager.php b/src/EnforcerManager.php
index aafa98b..dc5da35 100755
--- a/src/EnforcerManager.php
+++ b/src/EnforcerManager.php
@@ -7,6 +7,7 @@
 use Casbin\Model\Model;
 use Casbin\Log\Log;
 use Lauthz\Contracts\Factory;
+use Lauthz\Contracts\ModelLoader;
 use Lauthz\Models\Rule;
 use Illuminate\Support\Arr;
 use InvalidArgumentException;
@@ -86,12 +87,9 @@ protected function resolve($name)
         }
 
         $model = new Model();
-        $configType = Arr::get($config, 'model.config_type');
-        if ('file' == $configType) {
-            $model->loadModel(Arr::get($config, 'model.config_file_path', ''));
-        } elseif ('text' == $configType) {
-            $model->loadModelFromText(Arr::get($config, 'model.config_text', ''));
-        }
+        $loader = $this->app->make(ModelLoader::class, $config);
+        $loader->loadModel($model);
+
         $adapter = Arr::get($config, 'adapter');
         if (!is_null($adapter)) {
             $adapter = $this->app->make($adapter, [
diff --git a/src/LauthzServiceProvider.php b/src/LauthzServiceProvider.php
index 56d1375..273f42b 100644
--- a/src/LauthzServiceProvider.php
+++ b/src/LauthzServiceProvider.php
@@ -3,6 +3,8 @@
 namespace Lauthz;
 
 use Illuminate\Support\ServiceProvider;
+use Lauthz\Contracts\ModelLoader;
+use Lauthz\Loaders\ModelLoaderFactory;
 use Lauthz\Models\Rule;
 use Lauthz\Observers\RuleObserver;
 
@@ -50,5 +52,9 @@ public function register()
         $this->app->singleton('enforcer', function ($app) {
             return new EnforcerManager($app);
         });
+
+        $this->app->bind(ModelLoader::class, function($app, $config) {
+            return ModelLoaderFactory::createFromConfig($config);
+        });
     }
 }
diff --git a/src/Loaders/FileLoader.php b/src/Loaders/FileLoader.php
new file mode 100644
index 0000000..e2845c2
--- /dev/null
+++ b/src/Loaders/FileLoader.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Lauthz\Loaders;
+
+use Casbin\Model\Model;
+use Illuminate\Support\Arr;
+use Lauthz\Contracts\ModelLoader;
+
+class FileLoader implements ModelLoader
+{
+    /**
+     * The path to the model file.
+     *
+     * @var string
+     */
+    private $filePath;
+
+    /**
+     * Constructor to initialize the file path.
+     *
+     * @param array $config
+     */
+    public function __construct(array $config)
+    {
+        $this->filePath = Arr::get($config, 'model.config_file_path', '');
+    }
+
+    /**
+     * Loads model from file.
+     *
+     * @param Model $model
+     * @return void
+     * @throws \Casbin\Exceptions\CasbinException
+     */
+    public function loadModel(Model $model): void
+    {
+        $model->loadModel($this->filePath);
+    }
+}
\ No newline at end of file
diff --git a/src/Loaders/ModelLoaderFactory.php b/src/Loaders/ModelLoaderFactory.php
new file mode 100644
index 0000000..8a7ef9f
--- /dev/null
+++ b/src/Loaders/ModelLoaderFactory.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Lauthz\Loaders;
+
+use Illuminate\Support\Arr;
+use Lauthz\Contracts\Factory;
+use InvalidArgumentException;
+
+class ModelLoaderFactory implements Factory
+{
+    /**
+     * Create a model loader from configuration.
+     *
+     * A model loader is responsible for a loading model from an arbitrary source.
+     * Developers can customize loading behavior by implementing
+     * the ModelLoader interface and specifying their custom class
+     * via 'model.config_loader_class' in the configuration.
+     *
+     * Built-in loader implementations include:
+     *  - FileLoader: For loading model from file.
+     *  - TextLoader: Suitable for model defined as a multi-line string.
+     *  - UrlLoader: Handles model loading from URL.
+     *
+     *  To utilize a built-in loader, set 'model.config_type' to match one of the above types.
+     *
+     * @param array $config
+     * @return \Lauthz\Contracts\ModelLoader
+     * @throws InvalidArgumentException
+     */
+    public static function createFromConfig(array $config) {
+        $customLoader = Arr::get($config, 'model.config_loader_class', '');
+        if (class_exists($customLoader)) {
+            return new $customLoader($config);
+        }
+
+        $loaderType =  Arr::get($config, 'model.config_type', '');
+        switch ($loaderType) {
+            case 'file':
+                return new FileLoader($config);
+            case 'text':
+                return new TextLoader($config);
+            case 'url':
+                return new UrlLoader($config);
+            default:
+                throw new InvalidArgumentException("Unsupported model loader type: {$loaderType}");
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Loaders/TextLoader.php b/src/Loaders/TextLoader.php
new file mode 100644
index 0000000..ff4fa78
--- /dev/null
+++ b/src/Loaders/TextLoader.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Lauthz\Loaders;
+
+use Casbin\Model\Model;
+use Illuminate\Support\Arr;
+use Lauthz\Contracts\ModelLoader;
+
+class TextLoader implements ModelLoader
+{
+    /**
+     * Model text.
+     *
+     * @var string
+     */
+    private $text;
+
+    /**
+     * Constructor to initialize the model text.
+     *
+     * @param array $config
+     */
+    public function __construct(array $config)
+    {
+        $this->text = Arr::get($config, 'model.config_text', '');
+    }
+
+    /**
+     * Loads model from text.
+     *
+     * @param Model $model
+     * @return void
+     * @throws \Casbin\Exceptions\CasbinException
+     */
+    public function loadModel(Model $model): void
+    {
+//        dd($this->text);
+        $model->loadModelFromText($this->text);
+    }
+}
\ No newline at end of file
diff --git a/src/Loaders/UrlLoader.php b/src/Loaders/UrlLoader.php
new file mode 100644
index 0000000..f07c27c
--- /dev/null
+++ b/src/Loaders/UrlLoader.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Lauthz\Loaders;
+
+use Casbin\Model\Model;
+use Illuminate\Support\Arr;
+use Lauthz\Contracts\ModelLoader;
+use RuntimeException;
+
+class UrlLoader implements ModelLoader
+{
+    /**
+     * The url to fetch the remote model string.
+     *
+     * @var string
+     */
+    private $url;
+
+    /**
+     * Constructor to initialize the url path.
+     *
+     * @param array $config
+     */
+    public function __construct(array $config)
+    {
+        $this->url = Arr::get($config, 'model.config_url', '');
+    }
+
+    /**
+     * Loads model from remote url.
+     *
+     * @param Model $model
+     * @return void
+     * @throws \Casbin\Exceptions\CasbinException
+     * @throws RuntimeException
+     */
+    public function loadModel(Model $model): void
+    {
+        $contextOptions = [
+            'http' => [
+                'method'  => 'GET',
+                'header'  => "Accept: text/plain\r\n",
+                'timeout' => 3
+            ]
+        ];
+
+        $context = stream_context_create($contextOptions);
+        $response = @file_get_contents($this->url, false, $context);
+        if ($response === false) {
+            $error = error_get_last();
+            throw new RuntimeException(
+                "Failed to fetch remote model " . $this->url . ": " . $error['message']
+            );
+        }
+
+        $model->loadModelFromText($response);
+    }
+}
\ No newline at end of file
diff --git a/tests/ModelLoaderTest.php b/tests/ModelLoaderTest.php
new file mode 100644
index 0000000..75a93f3
--- /dev/null
+++ b/tests/ModelLoaderTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Lauthz\Tests;
+
+use Lauthz\Facades\Enforcer;
+use InvalidArgumentException;
+use RuntimeException;
+
+
+class ModelLoaderTest extends TestCase
+{
+    public function testUrlLoader(): void
+    {
+        $this->initUrlConfig();
+
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+
+        Enforcer::addPolicy('data_admin', 'data', 'read');
+        Enforcer::addRoleForUser('alice', 'data_admin');
+        $this->assertTrue(Enforcer::enforce('alice', 'data', 'read'));
+    }
+
+    public function testTextLoader(): void
+    {
+        $this->initTextConfig();
+
+        Enforcer::addPolicy('data_admin', 'data', 'read');
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+        $this->assertTrue(Enforcer::enforce('data_admin', 'data', 'read'));
+    }
+
+    public function testFileLoader(): void
+    {
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+
+        Enforcer::addPolicy('data_admin', 'data', 'read');
+        Enforcer::addRoleForUser('alice', 'data_admin');
+        $this->assertTrue(Enforcer::enforce('alice', 'data', 'read'));
+    }
+
+    public function testCustomLoader(): void
+    {
+        $this->initCustomConfig();
+        Enforcer::guard('second')->addPolicy('data_admin', 'data', 'read');
+        $this->assertFalse(Enforcer::guard('second')->enforce('alice', 'data', 'read'));
+        $this->assertTrue(Enforcer::guard('second')->enforce('data_admin', 'data', 'read'));
+    }
+
+    public function testMultipleLoader(): void
+    {
+        $this->testFileLoader();
+        $this->testCustomLoader();
+    }
+
+    public function testEmptyModel(): void
+    {
+        Enforcer::shouldUse('third');
+        $this->expectException(InvalidArgumentException::class);
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+    }
+
+    public function testEmptyLoaderType(): void
+    {
+        $this->app['config']->set('lauthz.basic.model.config_type', '');
+        $this->expectException(InvalidArgumentException::class);
+
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+    }
+
+    public function testBadUlrConnection(): void
+    {
+        $this->initUrlConfig();
+        $this->app['config']->set('lauthz.basic.model.config_url', 'http://filenoexists');
+        $this->expectException(RuntimeException::class);
+
+        $this->assertFalse(Enforcer::enforce('alice', 'data', 'read'));
+    }
+
+    protected function initUrlConfig(): void
+    {
+        $this->app['config']->set('lauthz.basic.model.config_type', 'url');
+        $this->app['config']->set(
+            'lauthz.basic.model.config_url',
+            'https://raw.githubusercontent.com/casbin/casbin/master/examples/rbac_model.conf'
+        );
+    }
+
+    protected function initTextConfig(): void
+    {
+        $this->app['config']->set('lauthz.basic.model.config_type', 'text');
+        $this->app['config']->set(
+            'lauthz.basic.model.config_text',
+            $this->getModelText()
+        );
+    }
+
+    protected function initCustomConfig(): void {
+        $this->app['config']->set('lauthz.second.model.config_loader_class', '\Lauthz\Loaders\TextLoader');
+        $this->app['config']->set(
+            'lauthz.second.model.config_text',
+            $this->getModelText()
+        );
+    }
+
+    protected function getModelText(): string
+    {
+        return <<<EOT
+[request_definition]
+r = sub, obj, act
+
+[policy_definition]
+p = sub, obj, act
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
+EOT;
+    }
+}
\ No newline at end of file

From 96028609a9a04c3aad82b9498a3b814badf706ea Mon Sep 17 00:00:00 2001
From: Jon <techlee@qq.com>
Date: Sun, 16 Jun 2024 01:01:02 +0800
Subject: [PATCH 2/2] Apply suggestions from code review

---
 src/Loaders/TextLoader.php | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/Loaders/TextLoader.php b/src/Loaders/TextLoader.php
index ff4fa78..5257a3d 100644
--- a/src/Loaders/TextLoader.php
+++ b/src/Loaders/TextLoader.php
@@ -34,7 +34,6 @@ public function __construct(array $config)
      */
     public function loadModel(Model $model): void
     {
-//        dd($this->text);
         $model->loadModelFromText($this->text);
     }
 }
\ No newline at end of file