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 %} +
    +
    +

    + Here you can dry run and test login expression checker config. +

    + + + +
    + + +
    + +
    + + +
    +
    + +
    + {% 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 %}