diff --git a/config/services.yaml b/config/services.yaml
index 64dac3ca..bb6e82f2 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -223,3 +223,8 @@ services:
- [addTokenChecker, ['@Packeton\Security\Token\IntegrationTokenChecker']]
Symfony\Component\HttpClient\NoPrivateNetworkHttpClient: ~
+
+ Okvpn\Expression\TwigLanguage:
+ arguments:
+ $options:
+ cache: '%kernel.cache_dir%/expr'
diff --git a/docs/img/debug-expr.png b/docs/img/debug-expr.png
new file mode 100644
index 00000000..c3f2d7e6
Binary files /dev/null and b/docs/img/debug-expr.png differ
diff --git a/docs/oauth2.md b/docs/oauth2.md
index 8c98b854..5f116fdc 100644
--- a/docs/oauth2.md
+++ b/docs/oauth2.md
@@ -3,6 +3,7 @@
Table of content
---------------
- [Pull Request review](pull-request-review.md)
+- [Login Restriction](oauth2/login-expression.md)
- [GitHub Setup](oauth2/github-oauth.md)
- [GitHub App Setup](oauth2/githubapp.md)
- [GitLab Setup](oauth2/gitlab-integration.md)
@@ -21,6 +22,8 @@ packeton:
login_title: Login or Register with GitHub
clone_preference: 'api'
repos_synchronization: true
+ login_control_expression: "data['email'] ends with '@packeton.org'" # Restrict logic/register by custom condition.
+
pull_request_review: true # Enable pull request composer.lock review. Default false
# webhook_url: 'https://packeton.google.dev/' - overwrite host when setup webhooks
diff --git a/docs/oauth2/login-expression.md b/docs/oauth2/login-expression.md
new file mode 100644
index 00000000..d3b7df22
--- /dev/null
+++ b/docs/oauth2/login-expression.md
@@ -0,0 +1,98 @@
+# Limit login/register with using expression lang
+
+You may limit login with using expression, like symfony expression for access control. For evaluate expression
+used TWIG engine with customization by this lib [okvpn/expression-language](https://github.com/okvpn/expression-language).
+It allows to create a complex expressions where called team/members API to check that user belong to Organization/Repos etc.
+
+Example usage
+
+```yaml
+packeton:
+ integrations:
+ github:
+ allow_login: true
+ allow_register: true
+ github:
+ client_id: 'xxx'
+ client_secret: 'xxx'
+ login_control_expression: "data['email'] ends with '@packeton.org'"
+```
+
+Example 2. Here check GitLab's groups API.
+
+```yaml
+packeton:
+ integrations:
+ gitlab:
+ allow_login: true
+ allow_register: true
+ gitlab:
+ client_id: 'xx'
+ client_secret: 'xx'
+ login_control_expression: >
+ {% set members = api_cget('/groups/balaba/members') %}
+ {% set found = null %}
+ {% for member in members %}
+ {% if data['username'] and data['username'] == member['username'] %}
+ {% set found = member %}
+ {% endif %}
+ {% endfor %}
+
+ {% if found['access_level'] >= 50 %}
+ {% return ['ROLE_ADMIN', 'ROLE_GITLAB'] %}
+ {% elseif found['access_level'] >= 40 %}
+ {% return ['ROLE_MAINTAINER', 'ROLE_GITLAB'] %}
+ {% elseif found['access_level'] >= 10 %}
+ {% return ['ROLE_USER', 'ROLE_GITLAB'] %}
+ {% endif %}
+ {% return [] %}
+```
+
+### Custom Twig function for expression lang
+
+- `api_get(url, query = [], cache = true, app = null)` - Call get method
+- `api_cget(url, query = [], cache = true, app = null)` - Call get method with pagination with all pages.
+
+By default, the API call results are cached, but you may overwrite with `cache` param.
+
+
+`login_control_expression` - may return a bool result or list of roles. If returned result is empty - login/register is not allowed.
+
+## Debug expressions
+
+You may enable debugging by param
+
+```yaml
+packeton:
+ integrations:
+ gitlab:
+ login_control_expression_debug: true
+ login_control_expression: "data['email'] ends with '@packeton.org'"
+```
+
+For localhost, you also can enable symfony dev env. But it's **strongly** not recommended for prod for security reasons.
+Then you may use `dump` action.
+
+```
+APP_ENV=dev
+```
+
+```twig
+{% set members = api_cget('/groups/balaba/members') %}
+{% set found = null %}
+{% for member in members %}
+ {% if data['username'] and data['username'] == member['username'] %}
+ {% set found = member %}
+ {% endif %}
+{% endfor %}
+{% do dump(members) %}
+{% do dump(found) %}
+
+{% return [] %}
+```
+
+#### Example debug panel
+
+When `login_control_expression_debug` is enabled you may evaluate script from UI.
+
+[![Img](../img/debug-expr.png)](../img/debug-expr.png)
diff --git a/public/packeton/js/integration.js b/public/packeton/js/integration.js
index f3c713cf..60668485 100644
--- a/public/packeton/js/integration.js
+++ b/public/packeton/js/integration.js
@@ -2,8 +2,28 @@
"use strict";
let connBtn = $('.connect');
- connBtn.on('click', (e) => {
+ let form = $('#debug_integration');
+ form.on('submit', (e) => {
+ e.preventDefault();
+ let btn = form.find('.btn');
+ btn.addClass('loading');
+ let url = form.attr('action');
+ let formData = form.serializeArray();
+
+ $.post(url, formData, function (data) {
+ btn.removeClass('loading');
+ let html = '';
+ if (data.error) {
+ html += '
'+data.error+'
';
+ }
+ if (data.result) {
+ html += ''+data.result+'';
+ }
+ $('#result-container').html('');
+ });
+ });
+ connBtn.on('click', (e) => {
e.preventDefault();
let el = $(e.currentTarget);
let btn = el.find('.btn')
diff --git a/src/Controller/OAuth/IntegrationController.php b/src/Controller/OAuth/IntegrationController.php
index 2ce00ef0..22003783 100644
--- a/src/Controller/OAuth/IntegrationController.php
+++ b/src/Controller/OAuth/IntegrationController.php
@@ -14,6 +14,7 @@
use Packeton\Integrations\Exception\NotFoundAppException;
use Packeton\Integrations\IntegrationRegistry;
use Packeton\Integrations\AppInterface;
+use Packeton\Integrations\LoginInterface;
use Packeton\Integrations\Model\AppUtils;
use Packeton\Integrations\Model\FormSettingsInterface;
use Packeton\Integrations\Model\OAuth2State;
@@ -99,7 +100,7 @@ public function settings(Request $request, string $alias, #[Vars] OAuthIntegrati
[$formType, $formData] = $client instanceof FormSettingsInterface ? $client->getFormSettings($oauth) : [IntegrationSettingsType::class, []];
- $form = $this->createForm($formType, $oauth, $formData + ['repos' => $repos]);
+ $form = $this->createForm($formType, $oauth, $formData + ['repos' => $repos, 'api_config' => $client->getConfig()]);
$form->handleRequest($request);
$config = $client->getConfig($oauth);
@@ -192,6 +193,32 @@ public function deleteAction(Request $request, string $alias, #[Vars] OAuthInteg
return new RedirectResponse($this->generateUrl('integration_list'));
}
+ #[Route('/{alias}/{id}/debug', name: 'integration_debug', methods: ['POST'])]
+ public function debugAction(Request $request, string $alias, #[Vars] OAuthIntegration $oauth): Response
+ {
+ if (!$this->isCsrfTokenValid('token', $request->request->get('_token')) || !$this->canEdit($oauth)) {
+ return new Response('Invalid Csrf Form', 400);
+ }
+
+ $client = $this->getClient($alias, $oauth);
+ if (!$client->getConfig()->isDebugExpression() || !$client instanceof LoginInterface) {
+ throw $this->createAccessDeniedException('not allowed debug');
+ }
+
+ $context = $request->get('context');
+ $context = $context ? json_decode($context, true) : [];
+ $payload = $request->get('twig') ? trim($request->get('twig')) : null;
+
+ try {
+ $result = $client->evaluateExpression(['user' => $this->getUser(), 'data' => $context], $payload ?: null);
+ $result = is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_SLASHES);
+ } catch (\Throwable $e) {
+ return new JsonResponse(['error' => $e->getMessage()]);
+ }
+
+ return new JsonResponse(['result' => $result]);
+ }
+
protected function canDelete(OAuthIntegration $oauth): bool
{
return !$this->registry->getRepository(Package::class)->findOneBy(['integration' => $oauth]);
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 83e9443c..480fd03e 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -225,6 +225,14 @@ private function addIntegrationSection(ArrayNodeDefinition $rootNode, array $fac
->scalarNode('svg_logo')->end()
->scalarNode('logo')->end()
->scalarNode('login_title')->end()
+ ->scalarNode('login_control_expression')
+ ->beforeNormalization()
+ ->always(function ($value) {
+ return is_string($value) && str_contains($value, '{%') ? 'base64:' . base64_encode($value) : $value;
+ })
+ ->end()
+ ->end()
+ ->booleanNode('login_control_expression_debug')->end()
->booleanNode('allow_login')
->defaultFalse()
->end()
diff --git a/src/Entity/OAuthIntegration.php b/src/Entity/OAuthIntegration.php
index c6c919c0..afd8f9bf 100644
--- a/src/Entity/OAuthIntegration.php
+++ b/src/Entity/OAuthIntegration.php
@@ -222,6 +222,16 @@ public function isPullRequestReview(): ?bool
return $this->getSerialized('pull_request_review', 'boolean');
}
+ public function isUseForExpressionApi(): ?bool
+ {
+ return $this->getSerialized('use_for_expr', 'boolean');
+ }
+
+ public function setUseForExpressionApi(?bool $value = null): self
+ {
+ return $this->setSerialized('use_for_expr', $value);
+ }
+
public function getClonePreference(): ?string
{
return $this->getSerialized('clone_preference', 'string');
diff --git a/src/Form/Type/IntegrationSettingsType.php b/src/Form/Type/IntegrationSettingsType.php
index 4fdd4937..aab49105 100644
--- a/src/Form/Type/IntegrationSettingsType.php
+++ b/src/Form/Type/IntegrationSettingsType.php
@@ -5,8 +5,10 @@
namespace Packeton\Form\Type;
use Packeton\Entity\OAuthIntegration;
+use Packeton\Integrations\Model\AppConfig;
use Packeton\Util\PacketonUtils;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -61,6 +63,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'Disabled' => false,
],
]);
+
+ $config = $options['api_config'];
+ if ($config instanceof AppConfig) {
+ if ($config->hasLoginExpression()) {
+ $builder->add('useForExpressionApi', CheckboxType::class, ['required' => false]);
+ }
+ }
}
/**
@@ -71,6 +80,7 @@ public function configureOptions(OptionsResolver $resolver)
$resolver->setDefaults([
'data_class' => OAuthIntegration::class,
'repos' => [],
+ 'api_config' => null,
]);
}
}
diff --git a/src/Integrations/Base/BaseIntegrationTrait.php b/src/Integrations/Base/BaseIntegrationTrait.php
index 65a33527..eda0cb87 100644
--- a/src/Integrations/Base/BaseIntegrationTrait.php
+++ b/src/Integrations/Base/BaseIntegrationTrait.php
@@ -4,14 +4,17 @@
namespace Packeton\Integrations\Base;
+use Okvpn\Expression\TwigLanguage;
use Packeton\Entity\OAuthIntegration;
use Packeton\Integrations\Model\AppConfig;
+use Packeton\Integrations\Model\OAuth2ExpressionExtension;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
trait BaseIntegrationTrait
{
protected $defaultScopes = [''];
+ protected ?TwigLanguage $exprLang = null;
/**
* {@inheritdoc}
@@ -21,6 +24,57 @@ public function getConfig(OAuthIntegration $app = null, bool $details = false):
return new AppConfig($this->config + $this->getConfigApp($app, $details));
}
+ /**
+ * {@inheritdoc}
+ */
+ public function evaluateExpression(array $context = [], string $scriptPayload = null): mixed
+ {
+ if (null === $this->exprLang) {
+ $this->initExprLang();
+ }
+
+ $script = trim($this->getConfig()->getLoginExpression());
+ if (str_starts_with($script, '/') && file_exists($script)) {
+ if ($scriptPayload === $script) {
+ $scriptPayload = null;
+ }
+ $script = file_get_contents($script);
+ }
+
+ $scriptPayload ??= $script;
+ if (!str_contains($scriptPayload, '{%')) {
+ $scriptPayload = "{% return $scriptPayload %}";
+ }
+
+ return $this->exprLang->execute($scriptPayload, $context, true);
+ }
+
+ protected function initExprLang(): void
+ {
+ $this->exprLang = isset($this->twigLanguage) ? clone $this->twigLanguage : new TwigLanguage();
+ $repo = $this->registry->getRepository(OAuthIntegration::class);
+
+ $apiCallable = function(string $action, string $url, array $query = [], bool $cache = true, int $app = null) use ($repo) {
+ $baseApp = $app ? $repo->find($app) : $repo->findForExpressionUsage($this->name);
+ $key = "twig-expr:" . sha1(serialize([$action, $url, $query]));
+
+ return $this->getCached($baseApp, $key, $cache, function () use ($baseApp, $url, $action, $query) {
+ $token = $this->refreshToken($baseApp);
+ return match ($action) {
+ 'cget' => $this->makeCGetRequest($token, $url, ['query' => $query]),
+ default => $this->makeApiRequest($token, 'GET', $url, ['query' => $query]),
+ };
+ });
+ };
+
+ $funcList = [
+ 'api_cget' => fn () => call_user_func_array($apiCallable, array_merge(['cget'], func_get_args())),
+ 'api_get' => fn () => call_user_func_array($apiCallable, array_merge(['get'], func_get_args()))
+ ];
+
+ $this->exprLang->addExtension(new OAuth2ExpressionExtension($funcList));
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/src/Integrations/Github/GitHubIntegration.php b/src/Integrations/Github/GitHubIntegration.php
index cca3acd2..1a7cf47c 100644
--- a/src/Integrations/Github/GitHubIntegration.php
+++ b/src/Integrations/Github/GitHubIntegration.php
@@ -7,6 +7,7 @@
use Composer\Config;
use Composer\IO\IOInterface;
use Doctrine\Persistence\ManagerRegistry;
+use Okvpn\Expression\TwigLanguage;
use Packeton\Entity\OAuthIntegration as App;
use Packeton\Entity\User;
use Packeton\Integrations\AppInterface;
@@ -47,6 +48,7 @@ public function __construct(
protected ManagerRegistry $registry,
protected Scheduler $scheduler,
protected \Redis $redis,
+ protected TwigLanguage $twigLanguage,
protected LoggerInterface $logger,
) {
$this->name = $config['name'];
diff --git a/src/Integrations/Gitlab/GitLabIntegration.php b/src/Integrations/Gitlab/GitLabIntegration.php
index 2a727e47..572166dc 100644
--- a/src/Integrations/Gitlab/GitLabIntegration.php
+++ b/src/Integrations/Gitlab/GitLabIntegration.php
@@ -7,6 +7,7 @@
use Composer\Config;
use Composer\IO\IOInterface;
use Doctrine\Persistence\ManagerRegistry;
+use Okvpn\Expression\TwigLanguage;
use Packeton\Entity\OAuthIntegration;
use Packeton\Entity\OAuthIntegration as App;
use Packeton\Entity\User;
@@ -50,6 +51,7 @@ public function __construct(
protected LockFactory $lock,
protected ManagerRegistry $registry,
protected \Redis $redis,
+ protected TwigLanguage $twigLanguage,
protected LoggerInterface $logger,
) {
$this->name = $config['name'];
diff --git a/src/Integrations/LoginInterface.php b/src/Integrations/LoginInterface.php
index d23532dc..9a1de706 100644
--- a/src/Integrations/LoginInterface.php
+++ b/src/Integrations/LoginInterface.php
@@ -41,4 +41,13 @@ public function fetchUser(Request|array $request, array $options = [], array &$a
* @return User
*/
public function createUser(array $userData): User;
+
+ /**
+ * Login/Register expression check.
+ *
+ * @param array $context
+ * @param string|null $scriptPayload
+ * @return mixed
+ */
+ public function evaluateExpression(array $context = [], string $scriptPayload = null): mixed;
}
diff --git a/src/Integrations/Model/AppConfig.php b/src/Integrations/Model/AppConfig.php
index 501e40f6..5b1f9dbf 100644
--- a/src/Integrations/Model/AppConfig.php
+++ b/src/Integrations/Model/AppConfig.php
@@ -8,6 +8,7 @@
class AppConfig
{
+
public function __construct(protected array $config)
{
}
@@ -32,6 +33,22 @@ public function isPullRequestReview()
return $this->config['pull_request_review'] ?? false;
}
+ public function hasLoginExpression(): bool
+ {
+ return (bool)($this->config['login_control_expression'] ?? false);
+ }
+
+ public function isDebugExpression(): bool
+ {
+ return $this->config['login_control_expression_debug'] ?? false;
+ }
+
+ public function getLoginExpression(): ?string
+ {
+ $expr = $this->config['login_control_expression'] ?? null;
+ return $expr && str_starts_with($expr, 'base64:') ? base64_decode(substr($expr, 7)) : $expr;
+ }
+
public function isLogin(): bool
{
return $this->config['allow_login'] ?? false;
diff --git a/src/Integrations/Model/OAuth2ExpressionExtension.php b/src/Integrations/Model/OAuth2ExpressionExtension.php
new file mode 100644
index 00000000..36348ce0
--- /dev/null
+++ b/src/Integrations/Model/OAuth2ExpressionExtension.php
@@ -0,0 +1,37 @@
+extendFunction as $name => $func) {
+ $functions[] = new TwigFunction($name, $func);
+ }
+
+ return array_merge($functions, [
+ new TwigFunction('preg_match', 'preg_match'),
+ new TwigFunction('json_decode', fn ($data) => json_decode($data, true)),
+ new TwigFunction('hash_mac', 'hash_mac'),
+ new TwigFunction('array_unique', 'array_unique'),
+ new TwigFunction('dump', 'dump'),
+ ]);
+ }
+}
diff --git a/src/Integrations/Security/OAuth2Authenticator.php b/src/Integrations/Security/OAuth2Authenticator.php
index 95fc38b9..32e7d127 100644
--- a/src/Integrations/Security/OAuth2Authenticator.php
+++ b/src/Integrations/Security/OAuth2Authenticator.php
@@ -81,16 +81,30 @@ public function authenticate(Request $request): Passport
protected function loadOrCreateUser(LoginInterface $client, array $data): User
{
+ $config = $client->getConfig();
$repo = $this->registry->getRepository(User::class);
$user = $repo->findByOAuth2Data($data);
+
+ $em = $this->registry->getManager();
if ($user === null) {
- if (!$client->getConfig()->isRegistration()) {
+ if (!$config->isRegistration()) {
throw new CustomUserMessageAuthenticationException('Registration is not allowed');
}
+ $user = $client->createUser($data);
+ }
+
+ if ($config->hasLoginExpression()) {
+ $result = $client->evaluateExpression(['user' => $user, 'data' => $data]);
+ if (empty($result)) {
+ throw new CustomUserMessageAuthenticationException('Login is not allowed by custom rules');
+ }
- $em = $this->registry->getManager();
+ if (null === $user->getId() && is_array($result) && is_string($probe = $result[0] ?? null) && str_starts_with($probe, 'ROLE_')) {
+ $user->setRoles($result);
+ }
+ }
- $user = $client->createUser($data);
+ if (null === $user->getId()) {
$em->persist($user);
$em->flush();
}
diff --git a/src/Repository/OAuthIntegrationRepository.php b/src/Repository/OAuthIntegrationRepository.php
index 169b8cb2..206a2151 100644
--- a/src/Repository/OAuthIntegrationRepository.php
+++ b/src/Repository/OAuthIntegrationRepository.php
@@ -5,7 +5,25 @@
namespace Packeton\Repository;
use Doctrine\ORM\EntityRepository;
+use Packeton\Entity\OAuthIntegration;
class OAuthIntegrationRepository extends EntityRepository
{
+ public function findForExpressionUsage(string $alias): ?OAuthIntegration
+ {
+ /** @var OAuthIntegration[] $list */
+ $list = $this->createQueryBuilder('e')
+ ->where('e.alias = :alias')
+ ->setParameter('alias', $alias)
+ ->orderBy('e.id')
+ ->getQuery()->getResult();
+
+ foreach ($list as $item) {
+ if ($item->isUseForExpressionApi()) {
+ return $item;
+ }
+ }
+
+ return reset($list) ?: null;
+ }
}
diff --git a/templates/integration/index.html.twig b/templates/integration/index.html.twig
index cb875331..5399c48d 100644
--- a/templates/integration/index.html.twig
+++ b/templates/integration/index.html.twig
@@ -53,13 +53,22 @@
{% if canEdit %}
-
+
+ {% if config.hasLoginExpression() and config.debugExpression %}
+
+ {% endif %}
+
{% endif %}
{% if canDelete %}
@@ -70,11 +79,33 @@ If a different account is used, you may lose access to the current organization
{% endif %}
+
+ {% if canEdit and config.hasLoginExpression() and config.debugExpression %}
+
+ {% endif %}
-
{% if orgs|length > 0 %}
@@ -93,6 +124,7 @@ If a different account is used, you may lose access to the current organization
{% for org in orgs %}
{% set isConn = oauth.isConnected(org['identifier']) %}
{% set webhookInfo = oauth.getWebhookInfo(org['identifier']) %}
+ {% if canEdit or isConn %}
{% if org['logo'] is defined and org['logo'] %}
@@ -136,6 +168,7 @@ If a different account is used, you may lose access to the current organization
{% endif %}
+ {% endif %}
{% endfor %}