Skip to content

Commit 2d6f248

Browse files
committed
Implementing new endpoints for external attributes and corresponding ACL rules and scopes.
1 parent 9ca4061 commit 2d6f248

File tree

9 files changed

+274
-6
lines changed

9 files changed

+274
-6
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
namespace App\V1Module\Presenters;
4+
5+
use App\Exceptions\ForbiddenRequestException;
6+
use App\Exceptions\BadRequestException;
7+
use App\Model\Repository\GroupExternalAttributes;
8+
use App\Model\Repository\Groups;
9+
use App\Model\Entity\GroupExternalAttribute;
10+
use App\Model\View\GroupViewFactory;
11+
use App\Security\ACL\IGroupPermissions;
12+
use DateTime;
13+
14+
/**
15+
* Additional attributes used by 3rd parties to keep relations between groups and entites in external systems.
16+
* In case of a university, the attributes may hold things like course/semester/student-group identifiers.
17+
*/
18+
class GroupExternalAttributesPresenter extends BasePresenter
19+
{
20+
/**
21+
* @var GroupExternalAttributes
22+
* @inject
23+
*/
24+
public $groupExternalAttributes;
25+
26+
/**
27+
* @var Groups
28+
* @inject
29+
*/
30+
public $groups;
31+
32+
/**
33+
* @var IGroupPermissions
34+
* @inject
35+
*/
36+
public $groupAcl;
37+
38+
/**
39+
* @var GroupViewFactory
40+
* @inject
41+
*/
42+
public $groupViewFactory;
43+
44+
public function checkDefault()
45+
{
46+
if (!$this->groupAcl->canViewExternalAttributes()) {
47+
throw new ForbiddenRequestException();
48+
}
49+
}
50+
51+
/**
52+
* Return all attributes that correspond to given filtering parameters.
53+
* @GET
54+
* @Param(type="query", name="filter", required=true, validation="string",
55+
* description="JSON-encoded filter query in DNF as [clause OR clause...]")
56+
*
57+
* The filter is encocded as array of objects (logically represented as disjunction of clauses)
58+
* -- i.e., [clause1 OR clause2 ...]. Each clause is an object with the following keys:
59+
* "group", "service", "key", "value" that match properties of GroupExternalAttribute entity.
60+
* The values are expected values matched with == in the search. Any of the keys may be ommitted or null
61+
* which indicate it should not be matched in the particular clause.
62+
* A clause must contain at least one of the four keys.
63+
*
64+
* The endpoint will return a list of matching attributes and all related group entities.
65+
*/
66+
public function actionDefault(?string $filter)
67+
{
68+
$filterStruct = json_decode($filter ?? '');
69+
if (!$filterStruct || !is_array($filterStruct)) {
70+
throw new BadRequestException("Invalid filter format.");
71+
}
72+
73+
$attributes = $this->groupExternalAttributes->findByFilter($filterStruct);
74+
75+
$groupIds = [];
76+
foreach ($attributes as $attribute) {
77+
$groupIds[$attribute->getGroup()->getId()] = true; // id is key to make it unique
78+
}
79+
80+
$groups = $this->groups->groupsAncestralClosure(array_keys($groupIds));
81+
$this->sendSuccessResponse([
82+
"attributes" => $attributes,
83+
"groups" => $this->groupViewFactory->getGroups($groups),
84+
]);
85+
}
86+
87+
88+
public function checkAdd(string $groupId)
89+
{
90+
if (!$this->groupAcl->canSetExternalAttributes()) {
91+
throw new ForbiddenRequestException();
92+
}
93+
}
94+
95+
/**
96+
* Create an external attribute for given group.
97+
* @Param(type="post", name="service", required=true, validation="string:1..32",
98+
* description="Identifier of the external service creating the attribute")
99+
* @Param(type="post", name="key", required=true, validation="string:1..32",
100+
* description="Key of the attribute (must be valid identifier)")
101+
* @Param(type="post", name="value", required=true, validation="string:0..255",
102+
* description="Value of the attribute (arbitrary string)")
103+
* @POST
104+
*/
105+
public function actionAdd(string $groupId)
106+
{
107+
$group = $this->groups->findOrThrow($groupId);
108+
109+
$req = $this->getRequest();
110+
$service = $req->getPost("service");
111+
$key = $req->getPost("key");
112+
$value = $req->getPost("value");
113+
$attribute = new GroupExternalAttribute($group, $service, $key, $value);
114+
$this->groupExternalAttributes->persist($attribute);
115+
116+
$this->sendSuccessResponse("OK");
117+
}
118+
119+
public function checkRemove(string $groupId)
120+
{
121+
if (!$this->groupAcl->canSetExternalAttributes()) {
122+
throw new ForbiddenRequestException();
123+
}
124+
}
125+
126+
/**
127+
* Remove selected attribute
128+
* @DELETE
129+
*/
130+
public function actionRemove(string $id)
131+
{
132+
$attribute = $this->groupExternalAttributes->findOrThrow($id);
133+
$this->groupExternalAttributes->remove($attribute);
134+
$this->sendSuccessResponse("OK");
135+
}
136+
}

app/V1Module/presenters/GroupInvitationsPresenter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use DateTime;
1212

1313
/**
14-
* Hardware groups endpoints
14+
* Group invitations - links that allow users to join a group.
1515
*/
1616
class GroupInvitationsPresenter extends BasePresenter
1717
{

app/V1Module/router/RouterFactory.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public static function createRouter()
3838
$router[] = self::createAssignmentsRoutes("$prefix/exercise-assignments");
3939
$router[] = self::createGroupsRoutes("$prefix/groups");
4040
$router[] = self::createGroupInvitationsRoutes("$prefix/group-invitations");
41+
$router[] = self::createGroupAtrributesRoutes("$prefix/group-attributes");
4142
$router[] = self::createInstancesRoutes("$prefix/instances");
4243
$router[] = self::createReferenceSolutionsRoutes("$prefix/reference-solutions");
4344
$router[] = self::createAssignmentSolutionsRoutes("$prefix/assignment-solutions");
@@ -299,6 +300,21 @@ private static function createGroupInvitationsRoutes(string $prefix): RouteList
299300
return $router;
300301
}
301302

303+
/**
304+
* Adds all group external attributes endpoints to given router.
305+
* @param string $prefix Route prefix
306+
* @return RouteList All endpoint routes
307+
*/
308+
private static function createGroupAtrributesRoutes(string $prefix): RouteList
309+
{
310+
$router = new RouteList();
311+
312+
$router[] = new GetRoute($prefix, "GroupExternalAttributes:");
313+
$router[] = new PostRoute("$prefix/<groupId>", "GroupExternalAttributes:add");
314+
$router[] = new DeleteRoute("$prefix/<id>", "GroupExternalAttributes:remove");
315+
return $router;
316+
}
317+
302318
/**
303319
* Adds all Instances endpoints to given router.
304320
* @param string $prefix Route prefix

app/V1Module/security/ACL/IGroupPermissions.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,8 @@ public function canEditInvitations(Group $group): bool;
7575
public function canLockStudent(Group $group, User $student): bool;
7676

7777
public function canUnlockStudent(Group $group, User $student): bool;
78+
79+
public function canViewExternalAttributes(): bool;
80+
81+
public function canSetExternalAttributes(): bool;
7882
}

app/V1Module/security/TokenScope.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ class TokenScope
4242
*/
4343
public const EMAIL_VERIFICATION = "email-verification";
4444

45+
/**
46+
* Scope used for 3rd party tools designed to externally manage groups and student memeberships.
47+
*/
48+
public const GROUP_EXTERNAL_ATTRIBUTES = "group-external-attributes";
49+
50+
/**
51+
* Scope for managing the users. Used in case the user data needs to be updated from an external database.
52+
*/
53+
public const USERS = "users";
54+
4555
/**
4656
* Usually used in combination with other scopes. Allows refreshing the token.
4757
*/

app/config/permissions.neon

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ permissions:
7474
- viewPublicDetail
7575
- viewDetail
7676

77+
- allow: true
78+
role: scope-group-external-attributes
79+
resource: group
80+
actions:
81+
- viewExternalAttributes
82+
- setExternalAttributes
83+
- viewStudents
84+
- viewAll
85+
- viewPublicDetail
86+
- viewDetail
87+
- addStudent
88+
- removeStudent
89+
- addMember
90+
- removeMember
91+
7792
- allow: true
7893
role: student
7994
resource: group
@@ -372,6 +387,23 @@ permissions:
372387
- viewPublicData
373388
- viewList
374389

390+
- allow: true
391+
role: scope-users
392+
resource: user
393+
actions:
394+
- viewPublicData
395+
- viewGroups
396+
- viewDetail
397+
- viewList
398+
- viewAll
399+
- create
400+
- updateProfile
401+
- updatePersonalData
402+
- setIsAllowed
403+
- createLocalAccount
404+
- invalidateTokens
405+
- delete
406+
375407
- allow: true
376408
role: student
377409
resource: user

app/model/repository/ExerciseTags.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
*/
1212
class ExerciseTags extends BaseRepository
1313
{
14-
1514
public function __construct(EntityManagerInterface $em)
1615
{
1716
parent::__construct($em, ExerciseTag::class);

app/model/repository/GroupExternalAttributes.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use App\Model\Entity\GroupExternalAttribute;
77
use App\Model\Entity\User;
88
use Doctrine\ORM\EntityManagerInterface;
9+
use Doctrine\ORM\QueryBuilder;
10+
use InvalidArgumentException;
911

1012
/**
1113
* @extends BaseRepository<GroupExternalAttribute>
@@ -16,4 +18,63 @@ public function __construct(EntityManagerInterface $em)
1618
{
1719
parent::__construct($em, GroupExternalAttribute::class);
1820
}
21+
22+
/**
23+
* Helper function that constructs AND expression representing a filter clause for fiven query builder.
24+
* @param QueryBuilder $qb
25+
* @param int $counter used for generating unique parameter identifiers
26+
* @param array $clause represented as associative array
27+
* @param string $filterLocation used to identify the clause in exception messages
28+
* @throws InvalidArgumentException if the clause format is invalid
29+
*/
30+
private static function createFilterClause(QueryBuilder $qb, int &$counter, array $clause, string $filterLocation)
31+
{
32+
$expr = $qb->expr()->andX();
33+
$set = false;
34+
foreach (['group', 'service', 'key', 'value'] as $key) {
35+
if (array_key_exists($key, $clause) && $clause[$key] !== null) {
36+
if (!is_string($clause[$key])) {
37+
throw new InvalidArgumentException("Clause parameter $filterLocation.$key must be a string.");
38+
}
39+
$paramName = $key . $counter++;
40+
$expr->add("ea.$key = :$paramName");
41+
$qb->setParameter($paramName, $clause[$key]);
42+
$set = true;
43+
}
44+
}
45+
46+
if (!$set) {
47+
throw new InvalidArgumentException("Clause $filterLocation does not have any known keys.");
48+
}
49+
return $expr;
50+
}
51+
52+
/**
53+
* Find all external attributes that match given filter (disjunction of clauses).
54+
* @param array $filter clause represented as and array of OR clauses where each clause is an object
55+
* (or assoc. array) with a subset of 'group', 'service', 'key', 'value' keys
56+
* @return GroupExternalAttribute[] attributes that match given filter
57+
* @throws InvalidArgumentException if the clause format is invalid
58+
*/
59+
public function findByFilter(array $filter): array
60+
{
61+
if (!$filter) {
62+
throw new InvalidArgumentException("Arument filter is empty.");
63+
}
64+
65+
$qb = $this->createQueryBuilder('ea')->join('ea.group', 'g');
66+
$qb->where('g.archivedAt IS NULL');
67+
68+
$expr = $qb->expr()->orX();
69+
$counter = 0;
70+
foreach (array_values($filter) as $idx => $clause) {
71+
if (!is_object($clause) && !is_array($clause)) {
72+
throw new InvalidArgumentException("Invalid clause type in filter[$idx], object or array expected.");
73+
}
74+
$expr->add(self::createFIlterClause($qb, $counter, (array)$clause, "filter[$idx]"));
75+
}
76+
$qb->andWhere($expr);
77+
78+
return $qb->getQuery()->getResult();
79+
}
1980
}

app/model/repository/Groups.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
*/
1818
class Groups extends BaseSoftDeleteRepository
1919
{
20-
2120
public function __construct(EntityManagerInterface $em)
2221
{
2322
parent::__construct($em, Group::class);
@@ -287,18 +286,29 @@ public function findFiltered(
287286
/**
288287
* Gets an initial set of groups and produces a set of groups which have the
289288
* original set as a subset and every group has its parent in the set as well.
290-
* @param iterable $groups Initial set of groups
289+
* @param iterable $groups Initial set of groups or group IDs
291290
* @return Group[]
292291
*/
293292
public function groupsAncestralClosure(iterable $groups): array
294293
{
295294
$res = [];
296295
foreach ($groups as $group) {
297-
$groupId = $group->getId();
296+
// handle both IDs and group entities as input
297+
if (is_string($group)) { // load group if only ID is given
298+
$groupId = $group;
299+
$group = null;
300+
} else {
301+
$groupId = $group->getId();
302+
}
303+
298304
if (array_key_exists($groupId, $res)) { // @neloop made me do that
299305
continue;
300306
}
301-
$res[$groupId] = $group;
307+
308+
// add the group in the result
309+
$res[$groupId] = $group ?? $this->findOrThrow($groupId);
310+
311+
// ... along with the parents
302312
foreach ($group->getParentGroupsIds() as $id) {
303313
if (!array_key_exists($id, $res)) {
304314
$res[$id] = $this->findOrThrow($id);

0 commit comments

Comments
 (0)