From d0dac3de76d03bf0dc347d89ec5f730a47de958b Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Tue, 13 Jun 2023 00:27:51 +0200 Subject: [PATCH 1/5] OAuth2 integration allow login/register expression checker --- src/DependencyInjection/Configuration.php | 1 + .../Base/BaseIntegrationTrait.php | 42 +++++++++++++++++++ src/Integrations/LoginInterface.php | 8 ++++ src/Integrations/Model/AppConfig.php | 14 ++++++- .../Model/OAuth2ExpressionExtension.php | 36 ++++++++++++++++ .../Security/OAuth2Authenticator.php | 16 ++++++- 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/Integrations/Model/OAuth2ExpressionExtension.php diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 83e9443c..43617b58 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -225,6 +225,7 @@ private function addIntegrationSection(ArrayNodeDefinition $rootNode, array $fac ->scalarNode('svg_logo')->end() ->scalarNode('logo')->end() ->scalarNode('login_title')->end() + ->scalarNode('login_control_expression')->end() ->booleanNode('allow_login') ->defaultFalse() ->end() diff --git a/src/Integrations/Base/BaseIntegrationTrait.php b/src/Integrations/Base/BaseIntegrationTrait.php index 65a33527..502f0226 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,45 @@ public function getConfig(OAuthIntegration $app = null, bool $details = false): return new AppConfig($this->config + $this->getConfigApp($app, $details)); } + /** + * {@inheritdoc} + */ + public function evaluateExpression(array $context = []): mixed + { + if (null === $this->exprLang) { + $this->initExprLang(); + } + + $script = trim($this->config['login_control_expression']); + if (!str_starts_with($script, '{%')) { + $script = "{% return $script %}"; + } + + return $this->exprLang->execute($script, $context, true); + } + + protected function initExprLang(): void + { + $this->exprLang = new TwigLanguage(); + $repo = $this->registry->getRepository(OAuthIntegration::class); + $baseApp = $repo->findOneBy(['alias' => $this->name], ['id' => 'ASC']); + + $funcList = [ + 'api_cget' => function(string $url, array $query = [], int $app = null) use ($repo, $baseApp) { + $baseApp = $app ? $repo->find($app) : $baseApp; + $token = $this->refreshToken($baseApp); + return $this->makeApiRequest($token, 'GET', $url, ['query' => $query]); + }, + 'api_get' => function(string $url, array $query = [], int $app = null) use ($repo, $baseApp) { + $baseApp = $app ? $repo->find($app) : $baseApp; + $token = $this->refreshToken($baseApp); + return $this->makeCGetRequest($token, $url, ['query' => $query]); + }, + ]; + + $this->exprLang->addExtension(new OAuth2ExpressionExtension($funcList)); + } + /** * {@inheritdoc} */ diff --git a/src/Integrations/LoginInterface.php b/src/Integrations/LoginInterface.php index d23532dc..7cbfe515 100644 --- a/src/Integrations/LoginInterface.php +++ b/src/Integrations/LoginInterface.php @@ -41,4 +41,12 @@ 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 + * @return mixed + */ + public function evaluateExpression(array $context = []): mixed; } diff --git a/src/Integrations/Model/AppConfig.php b/src/Integrations/Model/AppConfig.php index 501e40f6..02a12d95 100644 --- a/src/Integrations/Model/AppConfig.php +++ b/src/Integrations/Model/AppConfig.php @@ -8,6 +8,8 @@ class AppConfig { + protected $overwriteRoles = null; + public function __construct(protected array $config) { } @@ -32,6 +34,11 @@ public function isPullRequestReview() return $this->config['pull_request_review'] ?? false; } + public function hasLoginExpression(): bool + { + return $this->config['login_control_expression'] ?? false; + } + public function isLogin(): bool { return $this->config['allow_login'] ?? false; @@ -63,9 +70,14 @@ public function getClientSecret(): ?string return $this->config['client_secret'] ?? null; } + public function overwriteRoles(array $roles = null): void + { + $this->overwriteRoles = $roles; + } + public function roles(): array { - return $this->config['default_roles'] ?? []; + return $this->overwriteRoles ?: ($this->config['default_roles'] ?? []); } public function getLogo(): ?string diff --git a/src/Integrations/Model/OAuth2ExpressionExtension.php b/src/Integrations/Model/OAuth2ExpressionExtension.php new file mode 100644 index 00000000..db8d2b6d --- /dev/null +++ b/src/Integrations/Model/OAuth2ExpressionExtension.php @@ -0,0 +1,36 @@ +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') + ]); + } +} diff --git a/src/Integrations/Security/OAuth2Authenticator.php b/src/Integrations/Security/OAuth2Authenticator.php index 95fc38b9..48edb145 100644 --- a/src/Integrations/Security/OAuth2Authenticator.php +++ b/src/Integrations/Security/OAuth2Authenticator.php @@ -81,10 +81,23 @@ public function authenticate(Request $request): Passport protected function loadOrCreateUser(LoginInterface $client, array $data): User { + $config = $client->getConfig(); + $config->overwriteRoles(); $repo = $this->registry->getRepository(User::class); $user = $repo->findByOAuth2Data($data); + if ($config->hasLoginExpression()) { + $result = $client->evaluateExpression(['user' => $user, 'data' => $data]); + if (empty($result)) { + throw new CustomUserMessageAuthenticationException('Registration is not allowed'); + } + + if (is_array($result) && is_string($result[0] ?? null) && str_starts_with($result[0], 'ROLE_')) { + $config->overwriteRoles($result); + } + } + if ($user === null) { - if (!$client->getConfig()->isRegistration()) { + if (!$config->isRegistration()) { throw new CustomUserMessageAuthenticationException('Registration is not allowed'); } @@ -95,6 +108,7 @@ protected function loadOrCreateUser(LoginInterface $client, array $data): User $em->flush(); } + $config->overwriteRoles(); return $user; } From 2314c63a2801f302458895045c4fe6a0a9b3f12f Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Tue, 13 Jun 2023 00:40:02 +0200 Subject: [PATCH 2/5] OAuth2 integration allow login/register expression checker --- src/Integrations/Security/OAuth2Authenticator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Integrations/Security/OAuth2Authenticator.php b/src/Integrations/Security/OAuth2Authenticator.php index 48edb145..7bc42b3a 100644 --- a/src/Integrations/Security/OAuth2Authenticator.php +++ b/src/Integrations/Security/OAuth2Authenticator.php @@ -88,7 +88,7 @@ protected function loadOrCreateUser(LoginInterface $client, array $data): User if ($config->hasLoginExpression()) { $result = $client->evaluateExpression(['user' => $user, 'data' => $data]); if (empty($result)) { - throw new CustomUserMessageAuthenticationException('Registration is not allowed'); + throw new CustomUserMessageAuthenticationException('Login is not allowed by custom rules'); } if (is_array($result) && is_string($result[0] ?? null) && str_starts_with($result[0], 'ROLE_')) { From 0b4aaea92d99c8a505e01c132dbbb30e7ca6cdd4 Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Tue, 13 Jun 2023 03:53:39 +0200 Subject: [PATCH 3/5] OAuth2 integration allow login/register expression checker --- config/services.yaml | 5 ++ public/packeton/js/integration.js | 22 ++++++++- .../OAuth/IntegrationController.php | 29 +++++++++++- src/DependencyInjection/Configuration.php | 9 +++- src/Entity/OAuthIntegration.php | 10 ++++ src/Form/Type/IntegrationSettingsType.php | 10 ++++ .../Base/BaseIntegrationTrait.php | 46 ++++++++++++------- src/Integrations/Github/GitHubIntegration.php | 2 + src/Integrations/Gitlab/GitLabIntegration.php | 2 + src/Integrations/LoginInterface.php | 3 +- src/Integrations/Model/AppConfig.php | 19 ++++++-- .../Model/OAuth2ExpressionExtension.php | 3 +- .../Security/OAuth2Authenticator.php | 8 +++- src/Repository/OAuthIntegrationRepository.php | 18 ++++++++ templates/integration/index.html.twig | 39 ++++++++++++++-- 15 files changed, 195 insertions(+), 30 deletions(-) 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/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('
      '+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 43617b58..480fd03e 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -225,7 +225,14 @@ private function addIntegrationSection(ArrayNodeDefinition $rootNode, array $fac ->scalarNode('svg_logo')->end() ->scalarNode('logo')->end() ->scalarNode('login_title')->end() - ->scalarNode('login_control_expression')->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 502f0226..26e05182 100644 --- a/src/Integrations/Base/BaseIntegrationTrait.php +++ b/src/Integrations/Base/BaseIntegrationTrait.php @@ -27,37 +27,49 @@ public function getConfig(OAuthIntegration $app = null, bool $details = false): /** * {@inheritdoc} */ - public function evaluateExpression(array $context = []): mixed + public function evaluateExpression(array $context = [], string $scriptPayload = null): mixed { if (null === $this->exprLang) { $this->initExprLang(); } - $script = trim($this->config['login_control_expression']); - if (!str_starts_with($script, '{%')) { - $script = "{% return $script %}"; + $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_starts_with($scriptPayload, '{%')) { + $scriptPayload = "{% return $scriptPayload %}"; } - return $this->exprLang->execute($script, $context, true); + return $this->exprLang->execute($scriptPayload, $context, true); } protected function initExprLang(): void { - $this->exprLang = new TwigLanguage(); + $this->exprLang = isset($this->twigLanguage) ? clone $this->twigLanguage : new TwigLanguage(); $repo = $this->registry->getRepository(OAuthIntegration::class); - $baseApp = $repo->findOneBy(['alias' => $this->name], ['id' => 'ASC']); - $funcList = [ - 'api_cget' => function(string $url, array $query = [], int $app = null) use ($repo, $baseApp) { - $baseApp = $app ? $repo->find($app) : $baseApp; - $token = $this->refreshToken($baseApp); - return $this->makeApiRequest($token, 'GET', $url, ['query' => $query]); - }, - 'api_get' => function(string $url, array $query = [], int $app = null) use ($repo, $baseApp) { - $baseApp = $app ? $repo->find($app) : $baseApp; + $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 $this->makeCGetRequest($token, $url, ['query' => $query]); - }, + 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)); 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 7cbfe515..9a1de706 100644 --- a/src/Integrations/LoginInterface.php +++ b/src/Integrations/LoginInterface.php @@ -46,7 +46,8 @@ public function createUser(array $userData): User; * Login/Register expression check. * * @param array $context + * @param string|null $scriptPayload * @return mixed */ - public function evaluateExpression(array $context = []): 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 02a12d95..796582ae 100644 --- a/src/Integrations/Model/AppConfig.php +++ b/src/Integrations/Model/AppConfig.php @@ -8,7 +8,7 @@ class AppConfig { - protected $overwriteRoles = null; + protected static $overwriteRoles = null; public function __construct(protected array $config) { @@ -36,7 +36,18 @@ public function isPullRequestReview() public function hasLoginExpression(): bool { - return $this->config['login_control_expression'] ?? false; + 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 @@ -72,12 +83,12 @@ public function getClientSecret(): ?string public function overwriteRoles(array $roles = null): void { - $this->overwriteRoles = $roles; + self::$overwriteRoles = $roles; } public function roles(): array { - return $this->overwriteRoles ?: ($this->config['default_roles'] ?? []); + return self::$overwriteRoles ?: ($this->config['default_roles'] ?? []); } public function getLogo(): ?string diff --git a/src/Integrations/Model/OAuth2ExpressionExtension.php b/src/Integrations/Model/OAuth2ExpressionExtension.php index db8d2b6d..36348ce0 100644 --- a/src/Integrations/Model/OAuth2ExpressionExtension.php +++ b/src/Integrations/Model/OAuth2ExpressionExtension.php @@ -30,7 +30,8 @@ public function getFunctions(): array 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('array_unique', 'array_unique'), + new TwigFunction('dump', 'dump'), ]); } } diff --git a/src/Integrations/Security/OAuth2Authenticator.php b/src/Integrations/Security/OAuth2Authenticator.php index 7bc42b3a..451c79e7 100644 --- a/src/Integrations/Security/OAuth2Authenticator.php +++ b/src/Integrations/Security/OAuth2Authenticator.php @@ -91,7 +91,13 @@ protected function loadOrCreateUser(LoginInterface $client, array $data): User throw new CustomUserMessageAuthenticationException('Login is not allowed by custom rules'); } - if (is_array($result) && is_string($result[0] ?? null) && str_starts_with($result[0], 'ROLE_')) { + if (is_array($result) ) { + $probe = $result[0] ?? null; + if (!is_string($probe) || !str_starts_with($probe, 'ROLE_')) { + $this->logger->error("OAuth2 expression error, return result must be list of a valid roles"); + throw new CustomUserMessageAuthenticationException('OAuth2 login failed by invalid expression configuration'); + } + $config->overwriteRoles($result); } } 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 %}
    From 0a6f52126689a9d193220d00cbb836e32d0579d4 Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Wed, 14 Jun 2023 23:09:34 +0200 Subject: [PATCH 4/5] oauth2 login condition expression checker --- .../Base/BaseIntegrationTrait.php | 2 +- src/Integrations/Model/AppConfig.php | 8 +---- .../Security/OAuth2Authenticator.php | 30 ++++++++----------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/Integrations/Base/BaseIntegrationTrait.php b/src/Integrations/Base/BaseIntegrationTrait.php index 26e05182..eda0cb87 100644 --- a/src/Integrations/Base/BaseIntegrationTrait.php +++ b/src/Integrations/Base/BaseIntegrationTrait.php @@ -42,7 +42,7 @@ public function evaluateExpression(array $context = [], string $scriptPayload = } $scriptPayload ??= $script; - if (!str_starts_with($scriptPayload, '{%')) { + if (!str_contains($scriptPayload, '{%')) { $scriptPayload = "{% return $scriptPayload %}"; } diff --git a/src/Integrations/Model/AppConfig.php b/src/Integrations/Model/AppConfig.php index 796582ae..5b1f9dbf 100644 --- a/src/Integrations/Model/AppConfig.php +++ b/src/Integrations/Model/AppConfig.php @@ -8,7 +8,6 @@ class AppConfig { - protected static $overwriteRoles = null; public function __construct(protected array $config) { @@ -81,14 +80,9 @@ public function getClientSecret(): ?string return $this->config['client_secret'] ?? null; } - public function overwriteRoles(array $roles = null): void - { - self::$overwriteRoles = $roles; - } - public function roles(): array { - return self::$overwriteRoles ?: ($this->config['default_roles'] ?? []); + return $this->config['default_roles'] ?? []; } public function getLogo(): ?string diff --git a/src/Integrations/Security/OAuth2Authenticator.php b/src/Integrations/Security/OAuth2Authenticator.php index 451c79e7..32e7d127 100644 --- a/src/Integrations/Security/OAuth2Authenticator.php +++ b/src/Integrations/Security/OAuth2Authenticator.php @@ -82,39 +82,33 @@ public function authenticate(Request $request): Passport protected function loadOrCreateUser(LoginInterface $client, array $data): User { $config = $client->getConfig(); - $config->overwriteRoles(); $repo = $this->registry->getRepository(User::class); $user = $repo->findByOAuth2Data($data); + + $em = $this->registry->getManager(); + if ($user === null) { + 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'); } - if (is_array($result) ) { - $probe = $result[0] ?? null; - if (!is_string($probe) || !str_starts_with($probe, 'ROLE_')) { - $this->logger->error("OAuth2 expression error, return result must be list of a valid roles"); - throw new CustomUserMessageAuthenticationException('OAuth2 login failed by invalid expression configuration'); - } - - $config->overwriteRoles($result); + if (null === $user->getId() && is_array($result) && is_string($probe = $result[0] ?? null) && str_starts_with($probe, 'ROLE_')) { + $user->setRoles($result); } } - if ($user === null) { - if (!$config->isRegistration()) { - throw new CustomUserMessageAuthenticationException('Registration is not allowed'); - } - - $em = $this->registry->getManager(); - - $user = $client->createUser($data); + if (null === $user->getId()) { $em->persist($user); $em->flush(); } - $config->overwriteRoles(); return $user; } From 6eb03f496519b1f684866e03a291bc38d4b8f7c8 Mon Sep 17 00:00:00 2001 From: Uladzimir Tsykun Date: Wed, 14 Jun 2023 23:35:36 +0200 Subject: [PATCH 5/5] Login control expressions usage example --- docs/img/debug-expr.png | Bin 0 -> 28297 bytes docs/oauth2.md | 3 + docs/oauth2/login-expression.md | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 docs/img/debug-expr.png create mode 100644 docs/oauth2/login-expression.md diff --git a/docs/img/debug-expr.png b/docs/img/debug-expr.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f2d7e69046c40c3403d7d72e39f6629cd4b3d6 GIT binary patch literal 28297 zcmc$`byS?qlQ0@52}uYZJQ>`BI|LZqg6lwn3=Y9zun+tJ_y zf4h6nJ^SsqyMNt2oKw&6^wV8kRoz|I?f+F?;_Yj~*N+}OdMhOfRC@I2nc|~IPgGt$ zLH$Pw%;bUsPwhmcR9?P(IlHW|g!=j3UQEMY*&1x`^xf9zk+O+{y}gmG!LOlLj~;z| zBn1>!ah}xEDI)3IaXd@L8goku=yw^cfoc!ZQKPSd%`9et#RV73|k6>ikaX zFA)ZJbiHq8Bw&g-D@kA=0Uf2k8pp`#AHwJUTuZW z6}uM?ZvPrIpM1=hvWe;#RhDRtLYe+~dSpoYzk**j9{*GSe-6F;n)Pq8#Aa_HuiVf4 zDhr=hW#QT5q8e$2^YEV&M%k>Z2bL_-a2z*Wy*gm<#jLimbI%58<5x!`_yu{%>e0qGkAo)EUHHx2oqg^Gj4qVc~p8f+XE+xYT(P{adreQ>orzi^hAIFtg78K?q3!bISCKUWY$RU);oUJN83`~1u zq|fc&400EfY8{(qv-P@|w>YpNH(#A1tf}lDWQkRQd_Z1aK|#Gb=H7inZa$BcsIBOS z)NE@Xf4@DLzT;hNa<}K)e5$r;?krnz=>(15Z&^QLd$_XWY2(0x?sZ2!aRnT-^5F z7z^H-kLbRR#>R}^-Kr-!?Iz8K%w+(=hP4GN?;joYTes4fj&dHG9QO3Tc^z1B6yE%n zT}dJmZ?T|K$VYO8_%=btGd?U!OVo6{LzJsrV7_TbkWyaM4Fxz4)U5Y;@(%RuuyBkr%6~O82WU zQ7^X0GVB*>P`5dQ!DoBVRbvYkj{lfBeVrrSVGr8q`%`0U>UYjEnREN?wXzb@%hj^g zd~5zA7L>-8sA@r&j1+)=F{RuO=jLVnFw&8@I+01luI3e6pPYSMHrbuvRBiwKNWm=K z`ELOlihl|=8#X^mYbI1;)m2oK#cuW68mlBWiwEocpButlRFw}C^FTFLh!1Ex`}eYE z(K0W%>Jq)ZXU*MH*DG@rw0Z4!HEs>I+sn`A^{n;Wr~4qaJToVAB?-Uuq|Mt9d`=hk zcBK_gSUe~SJ2BmB6$}IzYX?3z%!hd__qCg2*eUkD*85;(J*5+K?UNM)WzvL74(-az z7)7?1gxLMS)9h7H@vJX}#Z|b1A@qxw@AP9XWp45==$LfMcVW|Kb!@7*?n(S&+4n)Y z40$sAaN_z#746T(D{}<&;aQbGp>V!L3(smsY*rJwMKw?C^Fdt6>QV?GJG0-cdFh`Dn z-SFw#v0*t%I3R<_OzVv4wi%P$8m8$-Td5=O>i_q~0HXQlJc zl*cui|7cvzipRnKl9;|#nr0fmE?Bwk-qTOnBs`XoW34tUfwq;FVj1aRbR8&5FW3~X zCFfs+W>U4RaL_{$3kTB39qBHQKE}L5`S``uX!|4Xf!w~;>L$Fuz^I= zl~0r2m31=nHiuh#m?}itu2}Ud-%v3oK*Ybon#i>{Q(p|c9sXj|I*UXH@6-scJ#Aw& z&KEjAZ*p6y~P7?UC{zW(Ohe)2y&JoHSxui^op3mXPCs(u|UX8D<%|q|QJM~DP zKYDbD?vj46nK!Hl*XgH&oPTvRo7B>XB}qY0IgOd>fZ&mo>X|8DY!tY&++PSF=qN; zUQi5Tps~EQ@SDi~tL&$cggp8?KH_)H*m!&Lt~h;W*O-A*f%g@X_bNE!*HH(_)1H#9 zBuFU!RHW*?*m}5P2g1FVy^ThK=Oq-nHxr6Ad>3%vp+;ooK9N_@tY}SZMJ#U?*e7&b zuy>-HZj}m;6n`eDqYJE48}%g{_;-ACfZJ z5>FOYbL;&bhm+YSe!6zRBGTz0$2T7p(nT0CWx@|egU@jGBvMUiY^kd76}NCkigIkc zO%xvAmU~wh&>n&yX+HrsTggSIkT>uFEKK;4M4b)0)$3@w?V?A3a=7O1qvO$0Kep}C zkO8tklm^@VkB|W-r;;lbCcT&|6{eYFl*E6SuuYPJuzs1GKGPSW?c3Cq`*EDXgT2+K z|C&f@nxWq%c4iJzFsczi0<7O0{+^6rea^ZwFq|ZOs>pekD#;8bHQSu&p5^|UM|)6g z$Cs}5Zm)z(+_I^$LK^3&99!nx`)1~}q=BGuy(iZIY(zHs5kG={9?w3#QgqVSEn5$A zKGCB~6QERe#QDLPq@>#BllJulx*Gw)sh!+$>tceN@z8i(L@}<0Emn{9W^`p-Ol^LU zOAUuJAvpI&`UMMl?(_32{d!_d>q&kJ{{>o+mhEnn?v9%B_X zt|3U?!@W1jvdvGZI_n7=w6ycuFj_i~z%|aVa(X8#9Hy7}QP`-DX};Eg zYH-04%x^3Kq`|MKXJUhbU;%+J5} zV4!huz(sE(fn9`Qh9|?DS4gd1q*B`+DJ)dwbj3z69(26PPM*BFbb+?6&B#Dr$K8+7~A*s&lcu~sFR zOWXP~liB=%hQrEyz6?NlCW`l%yH3^=!{HW(iWK1$P7x&o0DOs zG@EGZ&%lec=SXJ3#JqZ&G4u!vn?*^VU2gZh9D|3}WLXygcr2<~?#j_N>ZfJCU0?wd zu4MLJNKhgJeR$sfE}boK+fI(nXa_1AW%sk##-1&fVPOpU>4L?;+Cb@jcwSG`Ia^81 z*M+JC==l@1X6Krk*q1srK~9wA*yCD;h0VR*%YjyV4EZdI!6{G*py2_6UXfQ-Uanwh z`Zc25_=Bd)SwJ8d?xVzU^ZRj#uE>HRrML+-R+!|s)dE~)sr3bOypa48>xiDRmRU;@ zxYo3cYxQk9b$Bv~<}+5l5gi)PiCewcW59gfZh>bJWjQ%O*~ZAsza6+9uddA;_F60! zTF4>M$d!$&>A&}j|HFi8?UrW}RFdLjQQ)6L+bZ#HCPF4jzTPae!tDHfiwtfHNo`Hx zD|rVaP&^R$B&{5tOcmQpUyyZtOrEtOOhPcs12C7X&eqbl90zgQG0#+-1MzNC6=+2o4+4pd|t%mNzZV$pYB%V zE+=OczA>3t1)Lm6Zk)~rb%wC6(?{sZqXy#)EvhwKmK@ulbmg_XNFN>u>4M}Tg~;=* z<{(m^|3n{fxA=4$xfpMenS)#x-g!}(M-Hm%2uF=Rbc8J!K=Tdca zAZ@IhW+`SFFA3c3sWjf(ljRT!S@9SV`uq46@raR6WSA`CCv^%GGrSqd*EzEJ))8Vy zUlb?YZID;_lN0d~UGz!%98kKewnFZs;&>6iS`89sYNjs|+Orc$g#CrBQ)vTdCY@^B zETFc)>Pb2g0nSlp%9kN6={mw=0`cUKA!aHfBUV8ma9t;(M@V+!?+D=zjS{adjH;$N zzDTDvqj@2utYTvmSK;=^k#g^sZlmijUP;DrLW!L42+B=2ix3Ox`lq-jFi+IBv zScV!rvykY7Mb7cgR1Uoc3&rW1asJ`SY96VS?V)Op-8~vdOukoU{cdv6WrfmP6GsGP z-pO!LdM?`CFB3BfT39^1fxq9q`!%B#82uqU&gMPOT)pXeVWvZW)h{ynR33-@fCZGc zH#XCscfO%2=QUtLt)EP4HTdJk=U!`84$l<>6DGj?D` zSyCDeg?8CyXGan9!Wk_XtE@-Wx4^slbiD*84w0fc-G9{pSP*Xh$=*A~zIW&nv392O z?~Yw#kuv`3ql5f!{tM^8u&tmAxVp{m zJCyyVw-D)`BGDyy-H`LEtb{|Iumm)QGZ}VPQ-MzcbKJXw7^AVOA9(0WLYDpJ{R--8sWV$Q0oc(1a*kY|Th_H=wgWxm#S z`Mc2KjMgRd0E^ll?rcwQ`#@oh&Sk8olR2%CpstHs(UfsWZ=8`Mo_qgb*#>>5oDe?L zz46BxU4Gp$d|lUpCH`bX_bl6bvBJ~Rv0&){kz07a1KvNr2sJbGoEA>K$dNi2nReQa zqV?B)zzZ6tn;mLs#u$U7L7 z_btvdRRzq~jz(7AiE35F0mdrtidoUgZoQP%FjGy124g|&=~YlOhsmB*=6H+bhKfuQ zwk~8H6X6L1N=2tPV)3Es56v>4n*;a?VjJldTq&kHrFq<$jdQ8FRwm*P@p>-yXG=^} zB_8qos#TwFI;I9mtp{WzRmV&n6KY~y+)4w=cLIAh$SkJU(>Hd;{j59AY~}@}0OcBE zW}@;0e1qD#cg5TY+u)6$Q9QkxKLsyBp!J%&0;RV>YMHlZ;G1beThk=uKopbZI$jn8 z{0mx}s9J!bX1BkWu1;d~u1)nYZyJ}E+9s)|eKuY;JE37TOG0+dyMj($ zHSt8AU_z%n;#m{zgFZF0=X}Q^WOt!)e&Km1kDyGxg>m25Zlo8NcciKMkqXT94x~&( zRkLrNp6nW(I9T~zEE?~W8k|_5ji;5|rrFNE$&fQJ9e>j^p=eHq{DEtv@CT95g!p78 zcv;jxd3)!M@Gq+9s_4hR-Q&uhm!vN;%3>lz3*_)CE~>pLEq-0GTN2T(xE3LuS9B2DemZbLq-ipS`ADlzD<)a*n6btzS&Tg@xYjQ>Ms|FIyi;0PW|$t;*3Z5Z zV?R>)L{F{2BTuYtQ7QHb{dFtv;mg3jdzMW3ZtK|thA~WIi=Owlowe7;$FIvODw-D-^vmMR%~B2r zY#nVa=q*ngj3uOqxQKe)GbRSIp1%66qAcfjHOgzSr{y|R-$e5@q{1_MbN(RVY|=-G zc6=fN4)>vNeSBWeLQ0Z^!z?KtWV?TNJb$p$+oyz|Ru!|ihv=bW+$yFP=2Ks$Pu7C3 z)1MvVWN=jmr3%6o_^|5Mn-|4;Qt|4aB^HGf#k^9TLW zBM)ZX*ks2oiR5l6%=d*6e34M$R8&9wHa}7}{nKUqxA4nAe%S&^Agk0pK3dgK)3Z&C z^VZ!fYPa_edVQqWshU6rg11SIqrV@o*66lfqQ-Pn#EKEi)9PfhvQ1_F8-&@Yf!9p( zoTzh9a+P$l^FX-Xy`0WA_S|&Wy49;rRCHq9U_~HmfImF^fCVCaQwhzh=)7#mP2lDO z{2_-uOaSX>mq+`(>AeD)ms5fs3akfzhSCI#6tqKSqMo*jxEsYmFbvLj9A4;a`m)(l zp<|MtY#-3KR#~FP(Zw%klkl`R$M4P7=IljqSe_IbyW4yL8W_%U@Q2B1b?^PXy1OW6 z9wQJ4vyRyG!8kjZ9rWzFbV;;3jp-2E9yPwi0NF=0odkfDp$BVGy=SSxfiKT55Sx4- zx#_;g&S*6GLySrf=43|^p@;D&ZrmFmV=njW^OMv>-5R^urPr6|laRdz-BnxbebfXI>^l{O#EpVzc0N$V`mq=2>zzwXalS zo!Nv*w5O2X{Sx$&56$32Y#zDPK1PtgHfQi+j&dF?BndEL(Jq@voC0@$+7`jVcowOWP~7lUB$nD z_Ka;gMW8caC@J3#w}+!wBzebVy2ZzzZ|_Wh5=20uQ;xdcIYo0WTN(qNLdhdc#zz6C zvs|QG7}P~gTSOJ>-v<2M&3zBAu_NBixhwx-60n-cPG2}c@FGD2#;?3(lrEC$Bb#!? zoj!h&DhisroNi1$2r(~qkW;sJF~>t>eGe3rQveCxG%jvTCN07kg5PS#AQuhdXESgU z6jrLIVLRP&8`$hl{lL#UpY zkL`W2BalLGwBoSoZAm(4;wv|w&irPL(b3VFs9frD9yGuH_^3XV_@0-87Rp1+_uU9& zjF;UP+8+kAy53nDZmlCh`#!RV0WNG5=#dM3f~dZ- zA+WflMzgbRln*)Y1F^cRk}9+rm+02E;#49F400N8dCyjzL~hpri2@UqiZB6|(aGHg z{fMSM9+>s&n(+WbYV~F5ve$-q%f_~1_ls21-H}Elt0m~~*j)g!n7hV&*J$xFs>H7G z)>iI)lhCK_uk&e^{4j2aoZ`WAzI#^&04s-+W4cwS5MhFLkQz5N4 z?PevHgYUqkx4bS%B4giWkNp*>d;TT9+kO1L0>ig%*PNHuV^j|PMO+-)v}WsVE?%}V zk{=aA?l-@+42v#~d&Ue!ub?5ChL}&n*%#C@2osIRa{ZZZsbvcstto}ZWByEkR zkDfj9CHD&x5vfOKs;R=aMr}rW@?3f82EKF}zCc8S$SS~{>0lW?_7L)11=eU z?p|}blBT+~PZSgie7*RSlr`p)zM(f7ghi`p-!b~b;^B0Dv6Alxe_IPlvJo7d1=YRf zF`b&Qxb6j3U+CEz=)2w3u_enuuZxs=ioP0iwD>XA5!u~+Ek`25R(vsx{bkxnDUFs} zLNH6W_lEpwOxY9t_58z6DM&j)qKm+K$n%Ecurgp<7uuPpKQjYOqXOH(XeF3KXb5k` zANn0CIVQh6IU8wtb$P_&>bQH8zJRVO4SXV=|1v0^#6)^F`a1>PK<@-U8_kqD-ACJV zV!^RM<$@~ClTucT4N2xaw(tV|DU;|^eUHbBD?R9jX*R>ht5W`^@dYv2G8uTXDxcr_ zE+zl4j+bmM4QP&|SuFS7uGs`^zMUFgI@GubhenrkHd2>j2EL_AUCw>5g!>TxpsCiyEaxGtFS+mcSKCs}h`9}-b+{3rnEmz1U z!|!>KL85!`@3{cB1NJO-yXU8ScD%*DP+3T%ifP@y1P4E`{~bFY4X-%-hho=I)TFm> z=u6@;Q7W@5b_S*~wQi(+eZ>a8gR*@UG40SusiPrmH?Gu0!nZOq=k#1CN$gF$ER|X1 zHrzOm;qcbQ<@*66I~?|H(qzzAbZL3|+e;h}|7B9Te)_wyQyLn3tzsHJtM;6=I8Dzc`C}d#j>xTrkn1Z*J80fBiFJwhNB@l@wZMA1J75er5D*CNol_{E11NXW0vWe=I?&`tsp=tc z3iG4Wt>N;t8pX}}P>vfIDsJm+#)jcP(`^{6_Q4_}OQb$*eA~`Fq4)(iK%bbXvJ{lOjB$B*gtxQZNjBPD^5%3O9}9j9gZK) zKRIwq9$YV|3QDgCf_}gp-I_HAeOC+));CIF9bt#BNnOv=!qvORgi{=1!?pw8-aI}3 z*{9s0H#WEZsPSt7n}Ejdev_>M5bth4dM14oH<7e3CZR!RfzLjmq;TKd6|Q^#GGXGc z7oHUSg@Q&{gIzH{^Hs9~yTq01qqN;Sfp9rcCGWuiC!V}(+2Oz(e{6}7At~qJSk;LNH||CX zC%fwx+MCwRlKHwW?Rv{eM2^~AM(5PP>v-YO!-Rrgc7%-;>E^wIl9iF&$*;$+Fb)fU zxEL$&#^!90{hH@0U?C2hjUNQ!wcojT}j zZZdRex*K>K%S7hw`e3BqWykx$e1gsc#S_F@#ir2$?<7@HFYgwrD6CAF%+9S z=O^jJ9^4+2z%ZPJU?mjZACe{%_NN*v-?x-ZxbyYC`2D=xZ<2y^><1p}_Q?gqbkzKJ z*<3PF;IY-~jT~}6rW1$Vwd%kCiqJh6Nim!V+!?({j|B0(Y!0BafZXisMPd$gj4RFl(|kz&b{lF@O`5J zU%a1KL0-Lgno@i(NcH9Oq0)`EtjOQa`J4b zV_zX5-EaY~qPdQ}i1euqjQ6J>6%#giQHO<@3b)HyKD&sBtg)W0JidM9xu)6LKRD9U z@b)OLul9GIw3xF_Ci|*)Xj9!d$&(R}GOG2;7GPVlj9%qc6gy4ZAeiLC;?Qu)PB8i; z8SR=gtBvn3>yZj3EKI#06m06|pEaQjFA!qMQgq=QlLl{TMgF?t?`uRP6|8pu-EP#3 ztW89JsVva^>Qx69`&Rs1FuL-wy$cJ7jQ`iqq9DOunQn271L1=<8d`=?R$7k8X%o6O)U0mk3;nLiJgP z+X~5Nd#fOb=}PXmj%duPlVolorPx2NrSG=hmjnFuDj^<2T2Jd}Z~iXCb9}A8*Ci_* zhFyof=wKLQn&FnOiizf}U~MYR^R7fA&5cNIT?iFya=wFpG>-Yc?oHiK;_5QmYMx;D zb&QW1E8-W@qZv6cACE(03H~D|ewM;Pzv96b_OurQd8=FcvC_LJlqz*Z5}t9#YrAq6~k9)zX% zr-rkEx6uRxjJ&ge6h!Q4O;)Rhs*NE|$EO6yg^un16q6lZ7lgQDKQee!h2=%$)7{~I zrcwiMA9#QdJ~QC<;f zrYsprjaEJ<35>5+xp3^-AV^usL&b%PXLAw z(syfOG7XMY`I(HjUPBbO5MSz|tnoy_l+)}O@@i1*kdL$x+6L+9>cyRfYlmnJJ>;?& z=?SCr?bpT=k_6|oZ`tz>{QfuTK_&I2R&#DUiv7;KvRD+TIvM5GS8&?ByV;-P;{&MT zMdA<)c~Jr)741HLN75xEUX=yhVmn%QYo6Z6jG&>dx*E`=020USYfT0)cHr zW2pn7Gl;sCp(jE&?WjD*pPemt+vXAbhnT?tlS^g7S1J1wx7%;(B;=*ge8--X0yE>1Zq8A^sHBgGT+Z zgpy+W<>b(|=P7=YGV{t&&=BeSa&AhL`_SIoRqQGT@ENuLs_%|$+k0|E;T|^JrRVE! z>C2~5kR?}hdnvL`H0#gr$v-Oc`3yEc?_RyNnL}a6&!n(=`)|IqnyCzw5A31DKV)G2 z&tcdDcBt|{g()e08H)m-DvARqx1r1h4syd>y3fwK3Cn|X%>OjRpA|2W8XNIh0C?tW zz36f0g>w7w=dTjMOJ)V8xjsu-no3DY*qvQWi^nDtbD363pZR(&UDnPM7H?nsJ|%Ku z#jIx=y(oGaakCG=CpTsaw&3OdP@ zdzcvhto+U^4XUKjWAVHmPBP%xHebXzIeY&ebGtE)tZF~s_WZmrqto`T-c>AYtGQu& zcW|E2sybSbgLZudqo=n-uVotVGha6_;v2d$9WW!&9gWz`r*se>g znBlo}p5}dPVPW4?QG9?CVq=E4S&OWFdoBY6XnG3y2)ot^N3EPFuSCAvetpqLM1mJt zOb(bIR#lcjdyDaoki*rz+G}ec{$pnzo!l2g^{ulg2Uw12an6J2_wV3UQME;0oA2Fv z9BwyijF?ByUGc?#Kl6Ko@$Pke16?9HUZ4``lE4ANRq`n7HxwG}fLQLv3a4Z?O6;)q5OK!vheDqfx(&@y zW|$0aXtyHP_#BcdQ7gnPS9%|REax*@!6m6!nTMjCpNk+ivSoh^)a&bzt9_K3{%TiA`NF7JahuDk zXNYUQ4w;^#)H3JdbM#sdM?hnO(!PB^+24y*W_coxL(IBxuHJKTA!}hLOUmz5?jn8g z^4kLbUZxxx+x@k}4Du!A=&#!u!@jvN+2!tQrHca@8C}6@G?})|;*?bVSF^RAdRjd? zp+P=mld0Pn2h%4B!Ryh!z7jSeZM^)Cuq477cD=eEPFlzo zc<(iSQP8{=SyeIEEsZFtmXGmvX}~63fL^)E>5SBgo0dtG^~Ccc3$e&A6-FFe_{mFu z<=6Y$j!aR=2;%k|8qv|+%Xo{Sx%gYk02_K`p=)-JZ{CpxJNyoQDT2kpvr~1Rk0Rr1 zc`KTyK0nzgpod>yeh&T{lv;ACqkB6Z#4m-)3<;0GzSvH1)Z()ylk(W-Ax?XkB8O{W zQtZy7LhZ;|@CVx7AL1cUXfMxchW-!Fm}k1)NTPG&9nxI;4!+NoOe0d8)Uz#SKscI8c@6Vvu0ICEaAkeP5>A`l8&WbGM}Ra$~>ev zLOVkv<6WTCKt5lq zTDJ`YJudsgi0WUpnAq|09ubN#p8*W|*%bRfrd!e7e73M&;&-Ss0jRt~Z(>r-Za|Dt ztYAdFPLACwliGP6x4%;93ia;k2>uEM?EAslq>w2+#LOd=MblbR9I->7UZ z#`A8`h!gNUP18OKYq&zn(nQXKy;`cKA1xiGAp4j6g^HMBvy`5!n9rc1Y9oHb_F zc4bx4fBI>pS^U-dlNooR_|wg~9d_movZK2$(21eP7`qeFNh8on_g%Rfbo)6S` zl=7#ql?IM0M&JT_mExp!q@#Pz<3DIf5WfG_m&ty$EWQWEWlxJ)fsspIfBw;|mT;pM z?ItBv**XwpKj0~*|L11ms5>~i=U2?0SyDM+GS+vHj*Iv5HE0uEYz0N zrkc2#dGxDnX4e72va6FWXB%t3COc{5i>DzrCv6)F;r>Yk$~GshfG`?~Mz-UEZfcM8 zpX%^v3&_5y;Gc_RchicKQ{7L}!Wj!Op{aZpN_+W*k{bg*4s~!3R)U)UiRO8Zk-PbN zso?U=#Wj38Qv^|BhD)WUMucVVW8$LLfVlYYdqP|7v$H)fax5;_ur#4a3v}br6Vn>Xrm?f+yHFB3e)Ykd85uaPB?qTC2;g~1g! zG=(@Pz;}-+ioB@r6dw74=`f#4iyv`x2JKsa7z4t4)!$Xu@qv(?i6@m&_#?)q^XPzs zI@4WIi^T-MS6R6r-6s9JuKpx{AS#Yu*d|FkYQnrMj2^QUsTNG6Ww;_%9!7ajMBAhn zIL!sfNzoxB@le?jFU+z2siXi@$(zQGj57L&DQY2cA9u9?Tw`;+Y8#gtZdLyQ1gN)n zUG=tt5}DNn!P{<3EZ-}eQt)}`JE)4@sy}ON`kET|nHNfK8}qzRgHzR}WN0iG^mNlh z14d;t@|8rPO)`2FTkXwQpS$#KcWvcO`MTCI z_zu6?Y#`rEk4e4!NAo^>zr>Gm!;~}myH9#^#w8RK_kJN*_+Kl?za4RDHuz~i`+&uN ztDE1wFe?gm!+#fAyfwZUt@k;OCE%aWhKP+^H>!*YN*w<-8E({l`)p0PAENk>9`NYr zh7Ntr9$znfNNww|i%Ixj!D7_*H&tm($!!SbZi^ttd^Fmbcm8F|O~1V0U}4&W6R>tjV~FyIA}(90i4BKIsXJsl>b zI*rl#Q!>o-Z0>rCwD0X2(_`8|Pcx|U)iinpkh~#WpA+ z{w#2q!DM#VGo2b)7uhe4Ib0P<+g-1q!+!k4CN}JH>amlSai4DFVb*M0?cu&YYsv!r z`=&o2<+qiq{>`lFr%9pc^xrF$bQ)03z zwR~VS>?x_pJNNLizk4&}ZUeLKbI*f24`#Zf80!`AR1=;TgKk=>eeIo!h%y42f9}=` zU01iU>04T0AokzVQpFPZN=v3mTgVdJLs@Xvvw4zZ$sP${bhv5g~0C3_JBaS-AUnt5i2` zRz%BO0vqi+W}971u0Zm^$IYs=0@<+AchDIGZ(1rZEA3(IE=5Ha4qcY zBplx;EGAbUJEOiIzgz5MvGxpe@j(q#H34CFSBIoP;U^J7thxA{BlgaEp)#|z9fbg6 z)9F>yg8XIv)B`=E4+mcz6briLHDnhb));4-JW$q=pO)@Z#uUV9Q@d#5K<%!=O6Oq` zE*)$mm;4YBsvc6pl_MO%eT7k#o_KQ@Z<~sLGQBj*7TH=GGMal$@h%R&YCy z%C=%#4t(s*{~Nfr0GH8@-G5vH!wMhDaCNeg@-Mqf^72Eaz|2+EOK6(fO$Lo0NbY^I zW~Klf1$r%8JDR#FCTlEopER-ng|9ADOHDFA84-@aX>j|vf3 za^)Ykb?@j}zl{m>m;8vwH{l+)j6NzP-I$($JFhJQR5`x2u~b+6lM@D=)5F-ZT|eEX z642*yT^TDX#)H;}es#$WO)bpb{YmQm!uh3tSD8#`@k)ewR2`rXev&V4=>wRwf*rapK0w?+~*!hDfzg!Hmw3 zeZ(;Lxr%C7##hOp#Dc3eYw;b5i4k9su)_BxqK)IT7VXD7}P1p;R7n1Ai_fx?ga=OB6Q>6v;(gmRU#m1e%>3%s4 z#THD=@W5R}Y3IxR9q;myo)@_9VKAOpf?zybYHYa@EN)XZp~?HRFCfx7c*kd|Se@uv;&0csZH^@$phrwHUQtZb_=~>iBNjGhyv!S1 zeT*`Mc$w7-ENGbK6Zb`YdFDl)7@1{HMCY?w>^fL&fNSs_ce!f@gwqYnJEI_RN<;r+ zz6^PERuXa^ivxhFs2UC{^Kq=s@2C;3O{r*T#5Dcx~U+;qub!Lm! z{bpgGULVl#j#6N?p`m7E+%aPf?ust&cDD#%!_=e;(g7*>&r_~2r47^VR|M;1jQZytbs6ekgbH z-~CmCMWp?1`IcogP^3(vdA6)&@0M(ba{o&0+qrM;6tqdpv9w#D0 zqPf6+ zs&IPJU8*#P4v7dT($)v?u!WZ*dNVe~&uwroUJ@r&u`scY!aeQ(ZZR2bzObHs&*e@O z{&h~3V-q2GF+fUf%aPAj4}DJBQt?;$E}5Nv+yd{VfL>?->7JE=xw`uD1)10l2FrQS zPw#~- zny_m-r=Ub>n}}bHvdO_a_-H6S?r*c4GK9bE4CC$F`3hqNnVQC!MjAz;_iR}TuAeYf zaWdPAXh<#&wn*ieyb8Ee%MUXGO63#uKm0Badm|u1=_I+6Xiguc-U=okaD0FLZoSk@I{WkoRy-L5#wqSiO1xD==~@M%b$m^3pGl|ET>R5C4Zw{&R2t-`fAVxBnk-yX>7&T^y^CSzAg| zuJu1vuGwkYj$_&PHb)kxtqOC@5W|Z$c790qLcOgd`w6Bm@!?2oQ2N?~QxjIPaW$?tO3EAMeK-BR|&2 z*jan6Z>`Mr&H2qecfJ4gR(DB7(C)ZgUh`b4xy^0_OVoYIf7DR8lfAjiEY2%)Vj0xs z0Bb!|0yh>zb*M%2VF5THjpBpZ-01mMo(if$w(WN45c}uh?C$wv@|(SP7rfDd$u^^} zFuR#6-$(9ZoK09^*d7szhd^()-;^jHr$kxm09!|~+%duQn|%d=Nxcc`!im0)G?^PZ z);!w%OuPB16t_Fl?GTZ`B#rHM*WM-iJN4vV_qYp>-kfyyX5bl^v9* zss{@8r$g4YxLMN|yNl0|q zde4`t~ql|V@R-a&19MD<#d5^UVsP6hkr zsP#%QCc=;HM!@H}JDkh6R& z&NRB?K?7Qp@kdq%s2a(Uc9IbCo*hq-T-G+y;~plD+voT`%hlazPI!~ae{vQ23oNiB z0$ZtuuMnptUSQbiz2~Ony+y1tNFn`2)H7jyUxIT$-rFdM9@kV=_ujfO-eC$>U_I~ zr4R4@KK;x+rBsFeOV;+dk7Bc)PE30eSMf{Ub;J^?DTv|jwyM!RD)b)_256tJJfrXq z5gg>Txtibu36#?~ueJBBnYQ3Qq7qXPA26jO(i5g9&Z{@->DU{hXT~t1bVYf20~NUs z0v(#}VgLjEiaF`_5Q_?M=bT{2>*!&58) z>1}jEa}iMm<$oxjByCL69(1+J_Uj5PlW%)tzxjELovJv)S?nq7ku)CagQxm9CAWyA zA)QC5iiD={d5-z2Y`x!}1VoXuc+$x`{%a#adq7upW!xGt8RoqgN%cW9lV#*bozK

    6wG^cle^+@OX*8&QkxGBDe0zCGT}>TtVjzT6UIcg{UU(%2iXieGNvHZ26W zMbBi19Mm^A7Ax?Z;go2q6TK}WUBz4$(0);)%|1GD^**y^+QQ|`fU;%dFUv1!+PPCH z4I@JITr(Y`QxClyBUv4vEW8(Al7W{{ig_N{{^YJn^^mcc%favYlufgdPW)|sfu)V_ zPS;$7N&Ef$92sK(UMobo8&N4Gi)y%&RH6u}tEs%`8M*p$PFn21U$8B9$ovv^V@EQv zUM=RijQ%{4vz|H1DtlLf2<9*1`~2X>-jVobNdU&xmpdg!iK+U(-7Fa; zz=N;;y7HFRskdu=USGfL3VpY;FuBRRQiBR{lVu1dSw$H-7^<`M_vejg3p*vOQOLdB zL4042f*wC3=P9B={?X()=z^s+`1K5RC?k(ng$4(K#gtxq`6yQ6dulDj5=_NZvRkL(*CP8iCJ%v{5F6!R{xa~UAz9Cf4;)EjGy}HP4l~)s zq(d!RbLypeLtH8;3*?IB_ThKdvr9_rH=2Dz9Xr}vnTPr-dMGa=_MDQNPTm+@!;sd; zAnHxtLFmg`m@U`^iQes@gJK1%?EPE%!YuOX&*CJeKHO6m` z_?fGXYL|J8HjVX?M5TQll~;Bq%Pycrr8%V;7@ytOn3cWR&tX`^>#>w|?~W5IrPz&I z*49CUWDMUW0~Dq#UtJxhj^mFv()~Ir=W+{WOQgXK2-f2Ux{={d=1|0n&nW%6u| z_AJlFeBJ6Q!a^6XF}JAYZ;m#G`}WR&qMJ(vX^vnA2OvZf1m|uBg@LF=9v&VuM-+7Z zr4OUl3po(rpfre)^v};0l@U$U4Mm2x16&;)sT9uG3Vp^>vxeg3$hr*uK`G{InXj|w zT#E`!8(S0tbFe-elbz?#yD+J@+0}nEDJiKV7$EaTp*HLA{G>2^OH?p7Go|&2mCq?0 zGl8hCNQ1S~GNpa;L_ADRq#%80wK50I0G%4qj(eW2AH{q|O{CBL)L+#dX@I%0dC^w6 zviTQPz+9;7fqKv_>_+#F{Bp4rCR3=@f!?cno|Pp?NJg2b(XWMkp^i1=!FLaQU!Jc) z;BeTsE60t0$=}7=rr&l8-#h5!9sArc>c$L~gck6#+iQ0poD|KKPD*CeH9mY1Ft++4 zYP>Vha5J!2R+T#jwgYPqr*3b}W|ak)c%S!-kku6Mzk48}z>GqNBQP!|DoEI7&(avW zs{W$ZprA!V%c>tMM=qF|bJuJ3ZF_b%6EwWSCUJ!tD)&1F`pBH-KG@_g#>rbEZ*7ew zO}kFq_(P^aM8hthG4USFy4hf(n8|CmgV})XN7Ay=PjxEX(GcnM(Ch`BU)a|#CKA-u@MqRVG!0bK%zd6YvLVPdNi+#pw6-U7r?$0 zk7APaX^k~V*!c#xj*Kw#h--BT%*mJ10HMx}8x);^HM73TlBhLqpbS}w zjbiuw1Q7wl8KKUmJUIvyrpvu^HYG|hmuc(A&20^!9+THQ9|hbBc09q9j=5)TB1Gm7_d2FodN47wKo zuvh0;CLOl2wLT{LG;Swn%nqxyPWKWy2;8L+9m69*Zo)5{#giIvj(n;LLj&H03d^X( z+n~QjY%Dwzr44V*D&u!lzdNwDp0PKe_;5~3+^N2);=RuXI6rm3W488PTa~0S((S71 zfD%jHATC$jEPbQ1`}%T%92!MSr z*XU|nw?ZCqihRk$>@xI*XmQ%~7O6g*Z&KDR`=ZnHi>zxX)#1?5{@yHNON%Cpw#MQiTT?{=mrg1bnTS+_8-wp; zN=9DE-5t6!97OGkNMH9X?@w1gmz`l$jilzII>-N_(EtYhqM{ZP*DLO$?#%TzvACes z*IXWs3_<5eVjfJBNk*Zojei!2E0qWdUe4nV^-~40UW69~)_t$Y>hclpm#+rpYd!Fy zJ3U;2#jSUh8>IjEf4Cd@h8tLhsC?_}c2D-9%D3!dew zPd@5!imq?rf$nG%!oBcpy2DT61;IR&vP67H82hx`(Sp!U`AwFvV+a2WWDAeS<|6#& zHs>qfm7^XE#CE3Gnw|829B~)k$lx-P{WdMjJCpJ4Yqe z{!U@`9@f-6adhD0$xWr1%j7mPx~p&8?jwcCYqz7wGabKq_Ficrrz5J zta&cmDaxVqrFf{ZMWx{5>k8>-KXfaimw7f2BGfe`oZEU>I*oO-lfqeUwz#_R=gKPK z0>$V|&AsD=To?n?8&0>M5t~kp#C!|Cn>^djgXil&FrF2VH+4#Em z*>uY{{%8anM4Q;1D%KriG=*=bc)_5Is?FblYXXIxYGa_adq6V?y;w}hpQLiQ+w#=S z;(PCgSEcN%imzO;eSPm8t!$OJRH*_hx%ZBPqlNN#HKR=HH}?`)pqy~tqMHKxol&|a zv2LlrtrD|Fb~9`6*rA(VO4oC)+Ug5n3&E&M>69HsJ3OxD-CW{)(x{?>|0M_V^4eo_ zxyh<6e$FbmPCvy`c@MUpr@U!I&`H;8T>6N|l9v8h*i^Xv<{nSo@X?U91QfXEI}zAm zX~hn=%P{4>U2TcpAeODzy?ff$z}WaY58TsIzMMhO2^prWU=VDc*4Tx6@9c$aZ@Om4 zpfw0-lw$dYFO2l>EYCav#cEf1&o8nKsMWKb_2^-Lx8hZ@V)h1JUHKlu3AvJS7*c8r z+`EjOx50gCf2oeAlB$5bm~N?@pQjPM@!4FPWp&M#&1*2I=1tv7?#odvMhp9J#Qn9n z2OSwxy#3}_aP<4xKB;T(wnb>q!JwLa{4HPx+jTX;{w~w>1;xgtj6?d#vZ7?1b&KD8 zDJ{VsfW>271QQO6#`E-$x7lEq6bqoYAk=JqfKR$|@C9Y&DzCEX7@1$b#mcDi`0#|sP26Q6hlhoMg!@cwnuG`sL<_N8{iL3R!9N7~a zaR{ZCPHwpd7&i5c1&Cvsh_c<=`5YM$=C#?+LuRggu&L$ElvHx+IO7wBTG{o%6{`;8 zI%kZ1-v)f~4T~biyY7*9rV9To2N=K1J>+kpT&r{yBfSxI12=lf)>Ha`q_OWwL!^!R zi}9un1XuNGu$mh4w6U+VsAoI3Fy)YoQjGKT*S*dpFZQlnf8RB)!#^a(&w9p477E%j zdGC;w<;`AGLUW{&`Z)j_9}#_E8H~WXS8m7psP6ovLq4I&iIakSy;EO2XZbnzkD{jt z9o(}2+sX7VAcBvXn35_8T7w)VwzP+B1cK!LY@PwG>LrVAV zR;g^T?ZNd`niDlj=Nrq!vy-}Df%h56(=L3Zh*idN?`G-C~s^uA75-m1NSp)n~ z_hYyz->E%SO)&6PQx=#*?p|i*2DzmGDPXdDTq!)lIR;u0?fu)wJ>?w86X*)RX{E5r zlbriT-AI;pN^PqZcU|o`T*8C>Rihc!Pk){P;$-*t!XI@R28C@y(B=bL;;UH-y`pas zpB|c*a@LLqQrzUR?bSBO@P}2tEiHO79BglX{o~-5j$7Xh894+!4Cj5WWRjWI4_Ab% zK?kw$QH_!Uz1dotlRWf?Ufjs7cI>TJFlwIj)jbOJ;1iLOLZUp#O6CwmJ{8lkd4Z84 z9WYcdwJjSLu>rt3b#=YqKJ}E^VkViplsWh~a2r|Q&jKWgL-c%tP_ z?TQPSs$h9O*@&GoS~dl}`?5&Gm zEr-;wpq9l9ogfOWA!(WYR2%R%I^x3^^|@pH(MjQFe4@Leh9KbY1H4 z7)aBrtIPR(k=26-B44iT)b4bfp--O-h!liVT_sH?p5rRy>cUOpea*@j4#m3aNqI)hDJ->VBdBZ~Nok`FcV=?fybWE&jzaGE#o>;-F#cCVIPT$c0iu#QaHG02FEN}u?fD+R6aChcI+|`+iZculBi)n z$7omOXQtEn4FewSqPo_UhGdsDwiXnecDvXsT=D+B=%vn7m8)rZ2sf~yHD#gAxTA1T z_RM@QF=BG>c}j}i_3iq*G~1WaRnMeJI()Xt>xlD3D0h*n6?CgLJ{%heqze|hDxXNO zt6ff#)yap^;fkMaS`47OGrDRbqyG4U>4r_Dhuiw3>^9B@|7@#0JyBE>=oSRf{u4o= z(cn7<^PE3FO{Cq|3S-tt_mDl-mQEZZWYD#U$nja(?;`+Ka5d27SFd(@z#g#{lMMot zjgrYkb<%hPlwsGfw=wkKh2~iq=#`v%&3ZnMmcUN)m{&Py)DjBh>;nHeE9B!+q~*_} z4U5gBn;12+&q#W@pM2=Uc&P4OqRxay2M_RZOX7zL;nB&0k7TUikv6SjD_QX;gba$w zlM&dW77VWYAuYPatd#~?VIIMxvy58`z=m!03kd=w)3vIIQhZQA(9`dL?_Tb!Q~yLb z`dbC*?|t0ARgnI{)&DgG=D%GF+ZToY|JeRUdi?9+{I&59G{k>~+oxagg9i>A`)>ao z0e@ZAzc&7ZP4qt%w*TkW_n(P||5-F=Uq}7F#qh6i{k8G;xcvuXTRR?{4@9a|P8_iV z>Xqf*KAYl8+aAtOxCM$R1EN>lZgnzp@vG8dp#J5*hqMG~!G-B|8v9yl?thn7YCcD9 zL2QVG-{#t8qys?%*oHoN;YgEPw%mRJEc;ua*cG~HqeZvw+`K6Ib|oChwlo=xqz#7!iZMk0Rd+uMwP3tG*8p@ z)eX6CRdiVQjXdUC76MKv9oNf+eS)pF)P{*U*oq!eaz`8zo=>1mm-2LuF&BOJ1=B2` zA`NmZt(k+VY~oc7k^rCVE8;V#zq!dN()Hm?dB6pu7XBbXU5_)_?lUi1<0)Z{LcAiy zk_Z7O^Gtjxx=;?YD`ivkO||L4j)zWW-~GKWs7!BtcK}D+!eau|ZB>`JucV}qgos!Q zrMU>5_P^^TQpV$?P&n7exN3AP-lu zbxt$$yN=!3v2op@FzMad)q$F`2hB3+ddrjL+1c5Yy3PB<`ne-J=LAVfN~UtZ0zemy z8@MU7prS_>1tQm6*(1|ggv3)s?h%)TpMY@fVlyp3v4zK9njq(992zkuw@Ok0lt3H(#FBb5C-{{M4h zF;z5aPzO!V@oG5@qz1fyjcayr8Vz!GTmPE8RpXs?u5bC#`T~~(JQ>w*zkl>W^OHtw z#T4k%b;N!%t?Fw);8yXaT~rV?dFu=}Q5u+FNf38fh5r)L0-$_7DCd792>hM-`d|Ov z{`hjM%{O-6_bz4f(V4acX4fD1wAsy_>&$=>G%6qWXmzB@(S009Q#dQErQ}#l$I`5} zQKgW}=j3j$z7x#!I|k)p*6jO#&(u8`eGH9ISn=+i__g5zuYf5cWfQx#8=~JB&BX$V z4QJ-a9im%(D5X_CbDIz=BZ~4=AB$ z>^t(#ya9R?RP<{G-I)xX7^3HAAy)seB9S#r+ET>Scrm7-T34FNMdg1U)KZ{aB^Nu8m2i2O2b6EID<$&R!2Zdn_QEVcIU`H;7vm3|StwLYH?i2B~TETTH>Lh{yz7ntg) zfGi6H3v_Umw3CB<3ST`aub4H|I{$L$BWZ<)WzW-#MSzJ8;X+4bk;Z{Kdu7mleO zM)F~wK0VtJA}}wf-?z0ZdB$QB zaE36np?}yuZ8vJBmbK(`f0RpMIw}cM-#GE*a)hJbr110>CPo@MvB`Hy-B=uf7$E`! z*|o8Fd07LslM_aUYFfaD7$MflD5q34?xoI{&E}81S$Qp9BqdwlKRz*B{o=czF+Am7 zb)XYCvjv*8IC&B9*S#Y%FMg`Iq}^h)BOoFMso0&+epQMvq}_H53wGGd5=WrRAl|;I zVcfOc@;oXY?d#E5s)KtsPF6>LW5*-6U%dAj#OgdlJzRLfiqKCcJyjk^hVdl ztDRIT#)rm>cFNUZOT(O^@~X#$`0@!?j%izRWS(Wb99r(c0UN85UU)t~NK?}!v_ApV z^&&drQ}RTPtnn5Q+=Me&gBR3^4&H2Lt9C?jvC z*sxq_Rlw;~ii(b^ur~9}956cH{jEdl zM!D6k$d#V$Z9|?4>BsViT%eQy-Ty}=xj-cEcQGX<^+)pNeRktqddAh+r%*d@;#GHu_S8L*gDX)Wi zAXPzp{^xIA_*zBIQb7Dq_ko~~PCz||D*%xUUq#BWK7IUDWbH7jVjO**04_V(YxZR$ zV4qqGPG7ZcBgs$O#S-;v8SVtOEy&Qp4)d$rq>j(Ds9f(W$HD{UvG`(AdQiV@(8NNH zyYU#4OMF|*@G>mEpFw!=U?imBh{d(hF;ES6vA2F%@72x-_ym5{e}vs?3#o^9S7&rj zyGHnD%CzoDy26sYVh-+X)}{5`&2z*}N*`Nwt_x}JGwGGMs$gLsBYBCQ%OBG5fh`%V zS1Tx=>${x8b#(zU0jjrlOQ;aTma(*H9NcRsPC0&Tz!^7wa!|j{N__f`SOtf2v-P8? zHEQ{BQxQN+kTJPNA|u=0NH@+dv9_q<-h%e0AcK=o!>v}bjMLYUM{KQj@Jm`~oij?f&4Svv-6i79H`(sv ztBohmRZu#N;J4T|nH#I&x7gz@OG=;nUTVHJnVFYYPK-24r32<9_mfjzogU(YkEOVt zyXKUZGV$C_ zSjjUcIaXH;xK9;u1_Wz=Tj9s{#~t~!(s6DMxZ;Pyywg;|r>Q?zQ@=OeWR(l7iDUXe z;TM!m5yC+wLyh{=GPmPYc;3C|r;c4vjb!Do2PfHUEG3NE#pSM7+{FbeW?zbAWf7HQ z9wU4%N2`UIj#Xvm4H=z>P+L4Y?^-b@sy!%i{IIy)-o~d%HCW=9O9@?26Rm@B(Fy1q zZJov`Am4irmKq|K-K-IMh=C`&7pXHkV=@TNhGCt$dqY%Pj?!yXF|1+mw=C9B4uAObLV1+Or;oL&)%xkjmgpA^ z>+q!MtdeewV`>*O#8WLI3^$$ubkV3TT`Q5`)WB|mx8)#OCcitdr8;~m#Ui&5;FS3O zY(`PU5@{mWm|ABTQ`fb}AB=t_M4qyx`ket- z%)vQ>|4az}cRGP=d5tUNIkFj!4q+d5ffNMAO1p@Bm69OaRG`cUa?xf4o)1!ZyEmwt z3aLQPOF$h)ueJmi9#Qf%2MOQV&gSQ7jqekhKlZaEdw}0RY7UhE#`MqplE3#f{`Tts fQhsR3W09f|;j|lB`?LhW;lQn%Hl~%vzyJAf(HQ)| literal 0 HcmV?d00001 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)