Skip to content
Merged
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Business logic core of the application and a REST API that provides access to ot

### Prerequisites

You need a web server with PHP (8.1+) and MySQL or MariaDB database.
You need a web server with PHP (8.2+, 8.3 currently used in development) and MySQL or MariaDB database.

We recommend installing PHP from remi repository:

Expand All @@ -28,7 +28,7 @@ You may list the PHP modules thusly:
...and select the right module:

```
# dnf module enable php:remi-8.1
# dnf module enable php:remi-8.3
```

If you install core-api as a package, the PHP will be installed as dependencies.
Expand Down Expand Up @@ -65,7 +65,7 @@ installation to the end.

### Manual Installation

The web API requires a PHP runtime version at least 8.1. Which one depends on
The web API requires a PHP runtime version at least 8.2. Which one depends on
actual configuration, there is a choice between _mod_php_ inside Apache,
_php-fpm_ with Apache or Nginx proxy or running it as standalone uWSGI script.
Also see the required PHP modules in the prerequisites section.
Expand Down
115 changes: 69 additions & 46 deletions app/V1Module/presenters/GroupExternalAttributesPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@

namespace App\V1Module\Presenters;

use App\Exceptions\BadRequestException;
use App\Helpers\MetaFormats\Attributes\Post;
use App\Helpers\MetaFormats\Attributes\Query;
use App\Helpers\MetaFormats\Attributes\Path;
use App\Helpers\MetaFormats\Validators\VString;
use App\Helpers\MetaFormats\Validators\VUuid;
use App\Exceptions\ForbiddenRequestException;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Exceptions\InternalServerException;
use App\Model\Repository\GroupExternalAttributes;
use App\Model\Repository\GroupMemberships;
use App\Model\Repository\Groups;
use App\Model\Entity\GroupExternalAttribute;
use App\Model\View\GroupViewFactory;
use App\Security\ACL\IGroupPermissions;
use InvalidArgumentException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;

/**
* Additional attributes used by 3rd parties to keep relations between groups and entities in external systems.
Expand All @@ -28,6 +31,12 @@ class GroupExternalAttributesPresenter extends BasePresenter
*/
public $groupExternalAttributes;

/**
* @var GroupMemberships
* @inject
*/
public $groupMemberships;

/**
* @var Groups
* @inject
Expand All @@ -54,48 +63,41 @@ public function checkDefault()
}

/**
* Return all attributes that correspond to given filtering parameters.
* Return special brief groups entities with injected external attributes and given user affiliation.
* @GET
*
* The filter is encoded as array of objects (logically represented as disjunction of clauses)
* -- i.e., [clause1 OR clause2 ...]. Each clause is an object with the following keys:
* "group", "service", "key", "value" that match properties of GroupExternalAttribute entity.
* The values are expected values matched with == in the search. Any of the keys may be omitted or null
* which indicate it should not be matched in the particular clause.
* A clause must contain at least one of the four keys.
*
* The endpoint will return a list of matching attributes and all related group entities.
*/
#[Query("filter", new VString(), "JSON-encoded filter query in DNF as [clause OR clause...]", required: true)]
public function actionDefault(?string $filter)
#[Query("instance", new VUuid(), "ID of the instance, whose groups are returned.", required: true)]
#[Query(
"service",
new VString(),
"ID of the external service, of which the attributes are returned. If missing, all attributes are returned.",
required: false
)]
#[Query(
"user",
new VUuid(),
"Relationship info of this user is included for each returned group.",
required: false
)]
public function actionDefault(string $instance, ?string $service, ?string $user)
{
$filterStruct = json_decode($filter ?? '', true);
if (!$filterStruct || !is_array($filterStruct)) {
throw new BadRequestException("Invalid filter format.");
}

try {
$attributes = $this->groupExternalAttributes->findByFilter($filterStruct);
} catch (InvalidArgumentException $e) {
throw new BadRequestException($e->getMessage(), '', null, $e);
}

$groupIds = [];
foreach ($attributes as $attribute) {
$groupIds[$attribute->getGroup()->getId()] = true; // id is key to make it unique
}

$groups = $this->groups->groupsAncestralClosure(array_keys($groupIds));
$this->sendSuccessResponse([
"attributes" => $attributes,
"groups" => $this->groupViewFactory->getGroups($groups),
]);
$filter = $service ? [['service' => $service]] : [];
$attributes = $this->groupExternalAttributes->findByFilter($filter); // all attributes of selected service
$groups = $this->groups->findFiltered(null, $instance, null, false); // all but archived groups
$memberships = $user ? $this->groupMemberships->findByUser($user) : [];

$this->sendSuccessResponse($this->groupViewFactory->getGroupsForExtension(
$groups,
$attributes,
$memberships,
));
}


public function checkAdd()
public function checkAdd(string $groupId)
{
if (!$this->groupAcl->canSetExternalAttributes()) {
$group = $this->groups->findOrThrow($groupId);
if (!$this->groupAcl->canSetExternalAttributes($group)) {
throw new ForbiddenRequestException();
}
}
Expand All @@ -107,7 +109,7 @@ public function checkAdd()
#[Post("service", new VString(1, 32), "Identifier of the external service creating the attribute", required: true)]
#[Post("key", new VString(1, 32), "Key of the attribute (must be valid identifier)", required: true)]
#[Post("value", new VString(0, 255), "Value of the attribute (arbitrary string)", required: true)]
#[Path("groupId", new VString(), required: true)]
#[Path("groupId", new VUuid(), required: true)]
public function actionAdd(string $groupId)
{
$group = $this->groups->findOrThrow($groupId);
Expand All @@ -116,15 +118,21 @@ public function actionAdd(string $groupId)
$service = $req->getPost("service");
$key = $req->getPost("key");
$value = $req->getPost("value");
$attribute = new GroupExternalAttribute($group, $service, $key, $value);
$this->groupExternalAttributes->persist($attribute);

try {
$attribute = new GroupExternalAttribute($group, $service, $key, $value);
$this->groupExternalAttributes->persist($attribute);
} catch (UniqueConstraintViolationException) {
throw new BadRequestException("Attribute already exists.");
}

$this->sendSuccessResponse("OK");
}

public function checkRemove()
public function checkRemove(string $groupId)
{
if (!$this->groupAcl->canSetExternalAttributes()) {
$group = $this->groups->findOrThrow($groupId);
if (!$this->groupAcl->canSetExternalAttributes($group)) {
throw new ForbiddenRequestException();
}
}
Expand All @@ -133,11 +141,26 @@ public function checkRemove()
* Remove selected attribute
* @DELETE
*/
#[Path("id", new VUuid(), "Identifier of the external attribute.", required: true)]
public function actionRemove(string $id)
#[Query("service", new VString(1, 32), "Identifier of the external service creating the attribute", required: true)]
#[Query("key", new VString(1, 32), "Key of the attribute (must be valid identifier)", required: true)]
#[Query("value", new VString(0, 255), "Value of the attribute (arbitrary string)", required: true)]
#[Path("groupId", new VUuid(), required: true)]
public function actionRemove(string $groupId, string $service, string $key, string $value)
{
$attribute = $this->groupExternalAttributes->findOrThrow($id);
$this->groupExternalAttributes->remove($attribute);
$attributes = $this->groupExternalAttributes->findBy(
['group' => $groupId, 'service' => $service, 'key' => $key, 'value' => $value]
);
if (!$attributes) {
throw new NotFoundException("Specified attribute not found at selected group");
}
if (count($attributes) > 1) {
throw new InternalServerException(
"Unique constraint violation "
. "(multiple '$key' => '$value' attributes found at $groupId from service $service)"
);
}

$this->groupExternalAttributes->remove($attributes[0]);
$this->sendSuccessResponse("OK");
}
}
2 changes: 1 addition & 1 deletion app/V1Module/router/RouterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ private static function createGroupAttributesRoutes(string $prefix): RouteList

$router[] = new GetRoute($prefix, "GroupExternalAttributes:");
$router[] = new PostRoute("$prefix/<groupId>", "GroupExternalAttributes:add");
$router[] = new DeleteRoute("$prefix/<id>", "GroupExternalAttributes:remove");
$router[] = new DeleteRoute("$prefix/<groupId>", "GroupExternalAttributes:remove");
return $router;
}

Expand Down
2 changes: 1 addition & 1 deletion app/V1Module/security/ACL/IGroupPermissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ public function canUnlockStudent(Group $group, User $student): bool;

public function canViewExternalAttributes(): bool;

public function canSetExternalAttributes(): bool;
public function canSetExternalAttributes(Group $group): bool;
}
4 changes: 2 additions & 2 deletions app/V1Module/security/TokenScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ class TokenScope
public const EMAIL_VERIFICATION = "email-verification";

/**
* Scope used for 3rd party tools designed to externally manage groups and student memeberships.
* Scope used for 3rd party tools designed to externally manage groups and student memberships.
*/
public const GROUP_EXTERNAL_ATTRIBUTES = "group-external-attributes";
public const GROUP_EXTERNAL = "group-external";

/**
* Scope for managing the users. Used in case the user data needs to be updated from an external database.
Expand Down
23 changes: 18 additions & 5 deletions app/config/permissions.neon
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,32 @@ permissions:
- viewDetail

- allow: true
role: scope-group-external-attributes
role: scope-group-external
resource: group
actions:
- viewExternalAttributes

- allow: true
role: scope-group-external
resource: group
actions:
- setExternalAttributes
- viewStudents
- viewAll
- viewPublicDetail
- viewDetail
- addStudent
- removeStudent
- addMember
- removeMember
- addSubgroup
conditions:
- group.isNotArchived

- allow: true
resource: group
role: scope-group-external
actions:
- addSubgroup
conditions:
- group.isNotArchived
- group.isNotExam

- allow: true
role: student
Expand Down
2 changes: 1 addition & 1 deletion app/model/entity/GroupExternalAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class GroupExternalAttribute implements JsonSerializable
protected $service;

/**
* @ORM\Column(type="string", length=32)
* @ORM\Column(name="`key`", type="string", length=32)
* Key of the attribute under which it can be searched.
*/
protected $key;
Expand Down
14 changes: 14 additions & 0 deletions app/model/repository/GroupMemberships.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,18 @@ public function __construct(EntityManagerInterface $em)
{
parent::__construct($em, GroupMembership::class);
}

/**
* Find all group memberships for a specific user in non-archived groups.
* @param string $userId
* @return GroupMembership[]
*/
public function findByUser(string $userId): array
{
$qb = $this->createQueryBuilder('gm')->join('gm.group', 'g');
$qb->where('g.archivedAt IS NULL');
$qb->andWhere($qb->expr()->eq('gm.user', ':userId'))
->setParameter('userId', $userId);
return $qb->getQuery()->getResult();
}
}
Loading