Skip to content

Commit

Permalink
Merge pull request #133 from vtsykun/feat/oauth2-exprs
Browse files Browse the repository at this point in the history
OAuth2 integration allow login/register expression checker
  • Loading branch information
vtsykun authored Jun 14, 2023
2 parents 461a9ba + 6eb03f4 commit 08fca86
Show file tree
Hide file tree
Showing 18 changed files with 375 additions and 8 deletions.
5 changes: 5 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,8 @@ services:
- [addTokenChecker, ['@Packeton\Security\Token\IntegrationTokenChecker']]

Symfony\Component\HttpClient\NoPrivateNetworkHttpClient: ~

Okvpn\Expression\TwigLanguage:
arguments:
$options:
cache: '%kernel.cache_dir%/expr'
Binary file added docs/img/debug-expr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/oauth2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
98 changes: 98 additions & 0 deletions docs/oauth2/login-expression.md
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 21 additions & 1 deletion public/packeton/js/integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += '<li><div class="alert alert-warning">'+data.error+'</div></li>';
}
if (data.result) {
html += '<li>'+data.result+'</li>';
}
$('#result-container').html('<ul class="list-unstyled package-errors">'+html+'</ul>');
});
});

connBtn.on('click', (e) => {
e.preventDefault();
let el = $(e.currentTarget);
let btn = el.find('.btn')
Expand Down
29 changes: 28 additions & 1 deletion src/Controller/OAuth/IntegrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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]);
Expand Down
8 changes: 8 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions src/Entity/OAuthIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 10 additions & 0 deletions src/Form/Type/IntegrationSettingsType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
}
}
}

/**
Expand All @@ -71,6 +80,7 @@ public function configureOptions(OptionsResolver $resolver)
$resolver->setDefaults([
'data_class' => OAuthIntegration::class,
'repos' => [],
'api_config' => null,
]);
}
}
54 changes: 54 additions & 0 deletions src/Integrations/Base/BaseIntegrationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
*/
Expand Down
2 changes: 2 additions & 0 deletions src/Integrations/Github/GitHubIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'];
Expand Down
2 changes: 2 additions & 0 deletions src/Integrations/Gitlab/GitLabIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'];
Expand Down
9 changes: 9 additions & 0 deletions src/Integrations/LoginInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 08fca86

Please sign in to comment.