Skip to content

Commit eeadb7d

Browse files
committed
[#69419] frontend: handle user permissions and admin roles
1 parent 77d8773 commit eeadb7d

File tree

8 files changed

+148
-45
lines changed

8 files changed

+148
-45
lines changed

frontend/src/common/utils.ts

Lines changed: 91 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const DOWNLOAD_PACKAGE_ENDPOINT = (id: number) => `${PACKAGES_ENDPOINT}/$
3535

3636
export const DEVICES_ENDPOINT = `${SERVER_URL}/api/v2/devices`;
3737
export const PENDING_ENDPOINT = `${SERVER_URL}/api/v1/auth/pending`;
38+
export const PERMISSIONS_ENDPOINT = `${SERVER_URL}/api/v1/permissions`;
3839
export const REGISTER_DEVICE_ENDPOINT = `${SERVER_URL}/api/v1/auth/register`;
3940

4041
export const GROUPS_ENDPOINT = `${SERVER_URL}/api/v2/groups`;
@@ -49,6 +50,71 @@ export const LOGOUT_PATH = `${OIDC_LOGOUT_URL}?client_id=${OAUTH2_CLIENT}&post_l
4950

5051
export const POLL_INTERVAL = 2500;
5152

53+
/**
54+
* RDFM roles specified in
55+
* https://antmicro.github.io/rdfm/rdfm_mgmt_server.html#basic-configuration
56+
*/
57+
export enum AdminRole {
58+
RW = 'rdfm_admin_rw',
59+
RO = 'rdfm_admin_ro',
60+
UPLOAD_ROOTFS_IMAGE = 'rdfm_upload_rootfs_image',
61+
UPLOAD_SINGLE_FILE = 'rdfm_upload_single_file',
62+
}
63+
64+
/**
65+
* RDFM permissions specified in
66+
* https://antmicro.github.io/rdfm/rdfm_mgmt_server.html#permissions
67+
*/
68+
export type Permission = {
69+
permission: 'read' | 'update' | 'delete';
70+
resource: 'package' | 'group' | 'device';
71+
resource_id: number;
72+
user_id: string;
73+
};
74+
75+
export const adminRoles = ref<AdminRole[]>([]);
76+
export const permissions = ref<Permission[]>([]);
77+
78+
export const hasPermission = (
79+
permission: Permission['permission'],
80+
resource: Permission['resource'],
81+
resource_id?: number,
82+
) =>
83+
permissions.value.some(
84+
(p) => p.resource == resource && resource_id == p.resource_id && permission == p.permission,
85+
);
86+
87+
export const hasAdminRole = (r: AdminRole) => adminRoles.value.includes(r);
88+
89+
export const allowedTo = (
90+
permission: Permission['permission'],
91+
resource: Permission['resource'],
92+
id?: number,
93+
groups?: Group[],
94+
) => {
95+
if (hasAdminRole(AdminRole.RW) || (hasAdminRole(AdminRole.RO) && permission == 'read')) {
96+
return true;
97+
}
98+
99+
if (hasPermission(permission, resource, id)) {
100+
return true;
101+
}
102+
103+
if (id != null && groups) {
104+
const hasGroupPermission = groups
105+
.filter(
106+
(g) =>
107+
(resource == 'package' && g.packages.includes(id)) ||
108+
(resource == 'device' && g.devices.includes(id)),
109+
)
110+
.some((g) => allowedTo(permission, 'group', g.id));
111+
112+
return hasGroupPermission;
113+
}
114+
115+
return false;
116+
};
117+
52118
/**
53119
* Package interface specified in
54120
* https://antmicro.github.io/rdfm/api.html#get--api-v1-packages-response-json-array-of-objects
@@ -125,35 +191,35 @@ export enum PollingStatus {
125191
ActivePolling,
126192
}
127193

194+
export const fetchWrapper = async (
195+
url: string,
196+
method: string,
197+
headers: Headers = new Headers(),
198+
body?: BodyInit,
199+
) => {
200+
let response;
201+
try {
202+
const accessToken = localStorage.getItem('access_token');
203+
204+
if (accessToken) {
205+
headers.append('Authorization', `Bearer token=${accessToken}`);
206+
}
207+
response = await fetch(url, { method, body, headers: headers });
208+
if (!response.ok) {
209+
throw new Error(`Fetch returned status ${response.status}`);
210+
}
211+
const data = await response.json();
212+
return { success: true, code: response.status, data };
213+
} catch (e) {
214+
console.error(`Failed to fetch ${url} - ${e}`);
215+
return { success: false, code: response?.status, data: undefined };
216+
}
217+
};
218+
128219
export const resourcesGetter = <T>(resources_url: string) => {
129220
const resources: Ref<T | undefined> = ref(undefined);
130221
const pollingStatus: Ref<PollingStatus> = ref(PollingStatus.InitialPoll);
131222

132-
const fetchWrapper = async (
133-
url: string,
134-
method: string,
135-
headers: Headers = new Headers(),
136-
body?: BodyInit,
137-
) => {
138-
let response;
139-
try {
140-
const accessToken = localStorage.getItem('access_token');
141-
142-
if (accessToken) {
143-
headers.append('Authorization', `Bearer token=${accessToken}`);
144-
}
145-
response = await fetch(url, { method, body, headers: headers });
146-
if (!response.ok) {
147-
throw new Error(`Fetch returned status ${response.status}`);
148-
}
149-
const data = await response.json();
150-
return { success: true, code: response.status, data };
151-
} catch (e) {
152-
console.error(`Failed to fetch ${url} - ${e}`);
153-
return { success: false, code: response?.status, data: undefined };
154-
}
155-
};
156-
157223
const fetchGET = (url: string) => fetchWrapper(url, 'GET');
158224

159225
const fetchPOST = (url: string, headers: Headers, body: BodyInit) =>

frontend/src/components/devices/DevicesList.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ Component wraps functionality for displaying and working with rdfm devices.
7979
<div class="value">{{ device.public_key.slice(0, 15) }}...</div>
8080
</div>
8181
<div class="entry">
82+
<!-- TODO: Check a specific permission/role for registering devices once it's implemented server-side -->
8283
<button
8384
class="action-button blue"
85+
v-if="hasAdminRole(AdminRole.RW)"
8486
@click="registerDevice(device.mac_address, device.public_key)"
8587
>
8688
Register
@@ -139,7 +141,7 @@ Component wraps functionality for displaying and working with rdfm devices.
139141
>
140142
<span class="groupid">#{{ group?.id }}</span>
141143
{{
142-
group?.metadata?.['rdfm.group.name'] || 'Unnamed group'
144+
group?.metadata?.['rdfm.group.name'] || 'Unknown group'
143145
}}
144146
</div>
145147
</div>
@@ -259,7 +261,7 @@ Component wraps functionality for displaying and working with rdfm devices.
259261
<script lang="ts">
260262
import { computed, onMounted, onUnmounted } from 'vue';
261263
262-
import { useNotifications, POLL_INTERVAL } from '../../common/utils';
264+
import { useNotifications, POLL_INTERVAL, hasAdminRole, AdminRole } from '../../common/utils';
263265
import TitleBar from '../TitleBar.vue';
264266
import {
265267
groupResources,
@@ -327,6 +329,8 @@ export default {
327329
pendingDevicesCount,
328330
registeredDevicesCount,
329331
devicesCount,
332+
hasAdminRole,
333+
AdminRole,
330334
registerDevice,
331335
};
332336
},

frontend/src/components/groups/GroupsList.vue

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Component wraps functionality for displaying and working with rdfm groups.
142142
title="Groups"
143143
subtitle="manage your groups"
144144
actionButtonName="Create new group"
145-
:buttonCallback="openAddGroupPopup"
145+
:buttonCallback="hasAdminRole(AdminRole.RW) ? openAddGroupPopup : undefined"
146146
/>
147147

148148
<div class="container">
@@ -194,12 +194,14 @@ Component wraps functionality for displaying and working with rdfm groups.
194194
<div class="button-wrapper">
195195
<button
196196
class="action-button gray"
197+
v-if="allowedTo('update', 'group', group.id)"
197198
@click="openConfigureGroupPopup(group)"
198199
>
199200
Configure
200201
</button>
201202
<button
202203
class="action-button red"
204+
v-if="allowedTo('delete', 'group', group.id)"
203205
@click="openRemoveGroupPopup(group.id)"
204206
>
205207
Remove
@@ -250,7 +252,10 @@ Component wraps functionality for displaying and working with rdfm groups.
250252
:key="device.id"
251253
class="item"
252254
>
253-
<div class="item-layout grid">
255+
<div
256+
class="item-layout"
257+
:class="{ grid: allowedTo('update', 'group', group.id) }"
258+
>
254259
<div>
255260
<p title="MAC address">
256261
{{ device.mac_address || ' - ' }}
@@ -266,6 +271,7 @@ Component wraps functionality for displaying and working with rdfm groups.
266271
<button
267272
style="margin: 10px"
268273
class="action-button red small-padding"
274+
v-if="allowedTo('update', 'group', group.id)"
269275
@click="
270276
patchDevicesRequest(group.id, [], [device.id])
271277
"
@@ -416,22 +422,16 @@ Component wraps functionality for displaying and working with rdfm groups.
416422
</style>
417423

418424
<script lang="ts">
419-
import {
420-
computed,
421-
onMounted,
422-
onUnmounted,
423-
ref,
424-
type Ref,
425-
reactive,
426-
type Reactive,
427-
effect,
428-
} from 'vue';
425+
import { computed, onMounted, onUnmounted, ref, type Ref, reactive, type Reactive } from 'vue';
429426
import {
430427
POLL_INTERVAL,
431428
useNotifications,
429+
allowedTo,
432430
type Group,
433431
type Package,
434432
type RegisteredDevice,
433+
hasAdminRole,
434+
AdminRole,
435435
} from '../../common/utils';
436436
import {
437437
addGroupRequest,
@@ -896,6 +896,9 @@ export default {
896896
toggleDropdown,
897897
availablePolicies,
898898
unapplicablePolicyWarning,
899+
hasAdminRole,
900+
AdminRole,
901+
allowedTo,
899902
};
900903
},
901904
};

frontend/src/components/groups/groups.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const patchDevicesRequest = async (
9797
case StatusCodes.FORBIDDEN:
9898
return {
9999
success: false,
100-
message: 'User was authorized, but did not have permission to delete groups',
100+
message: 'User was authorized, but did not have permission to update groups',
101101
};
102102
case StatusCodes.NOT_FOUND:
103103
return { success: false, message: 'Group does not exist' };
@@ -348,7 +348,7 @@ export const removeGroupRequest = async (groupId: number): Promise<RequestOutput
348348
case StatusCodes.CONFLICT:
349349
return {
350350
success: false,
351-
message: 'At least one device is still assigned to the group',
351+
message: 'At least one device or package is still assigned to the group',
352352
};
353353
case StatusCodes.NOT_FOUND:
354354
return { success: false, message: 'The specified group does not exist' };

frontend/src/components/packages/PackagesList.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ Component wraps functionality for displaying and working with rdfm packages.
140140
<!-- TODO: Display metadata of the package -->
141141
<td class="entry">
142142
<div class="buttons">
143-
<div class="drdn-wrapper" style="position: relative">
143+
<div
144+
v-if="allowedTo('read', 'package', pckg.id, groups)"
145+
class="drdn-wrapper"
146+
style="position: relative"
147+
>
144148
<button id="main-button" class="action-button gray">
145149
Download
146150
<span class="caret-down"> <CaretDown /> </span>
@@ -193,6 +197,7 @@ Component wraps functionality for displaying and working with rdfm packages.
193197
</div>
194198
</div>
195199
<button
200+
v-if="allowedTo('delete', 'package', pckg.id, groups)"
196201
class="action-button red"
197202
@click="openRemovePackagePopup(pckg.id)"
198203
>
@@ -331,13 +336,13 @@ table.resources-table.packages {
331336

332337
<script lang="ts">
333338
import { computed, onMounted, onUnmounted, ref, type Ref, reactive, type Reactive } from 'vue';
334-
335-
import { POLL_INTERVAL, useNotifications } from '../../common/utils';
339+
import { POLL_INTERVAL, useNotifications, allowedTo } from '../../common/utils';
336340
import BlurPanel from '../BlurPanel.vue';
337341
import RemovePopup from '../RemovePopup.vue';
338342
import TitleBar from '../TitleBar.vue';
339343
import {
340344
packageResources,
345+
groupsResources,
341346
removePackageRequest,
342347
uploadPackageRequest,
343348
downloadPackageRequest,
@@ -479,6 +484,7 @@ export default {
479484
480485
onMounted(async () => {
481486
await packageResources.fetchResources();
487+
await groupsResources.fetchResources();
482488
483489
if (intervalID === undefined) {
484490
intervalID = setInterval(packageResources.fetchResources, POLL_INTERVAL);
@@ -503,6 +509,7 @@ export default {
503509
packageUploadData,
504510
validationErrors,
505511
packages: packageResources.resources,
512+
groups: groupsResources.resources,
506513
uploadedPackageFile,
507514
packagesCount,
508515
popupOpen,
@@ -513,6 +520,7 @@ export default {
513520
openAddPackagePopup,
514521
copyDownloadLink,
515522
download,
523+
allowedTo,
516524
};
517525
},
518526
};

frontend/src/components/packages/packages.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
import {
1313
DELETE_PACKAGE_ENDPOINT,
1414
DOWNLOAD_PACKAGE_ENDPOINT,
15+
GROUPS_ENDPOINT,
1516
PACKAGES_ENDPOINT,
1617
resourcesGetter,
18+
type Group,
1719
type Package,
1820
type RequestOutput,
1921
} from '../../common/utils';
@@ -26,6 +28,7 @@ export type NewPackageData = {
2628
};
2729

2830
export const packageResources = resourcesGetter<Package[]>(PACKAGES_ENDPOINT);
31+
export const groupsResources = resourcesGetter<Group[]>(GROUPS_ENDPOINT);
2932

3033
/**
3134
* Request specified in

frontend/src/views/HomeView.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,16 @@ SPDX-License-Identifier: Apache-2.0
191191
<script lang="ts">
192192
import { ref, computed, type PropType } from 'vue';
193193
import { useRoute, useRouter } from 'vue-router';
194+
import {
195+
fetchWrapper,
196+
LOGIN_PATH,
197+
LOGOUT_PATH,
198+
permissions,
199+
PERMISSIONS_ENDPOINT,
200+
adminRoles,
201+
type Permission,
202+
} from '../common/utils';
194203
195-
import { LOGIN_PATH, LOGOUT_PATH } from '../common/utils';
196204
import DevicesList from '../components/devices/DevicesList.vue';
197205
import PackagesList from '../components/packages/PackagesList.vue';
198206
import GroupsList from '../components/groups/GroupsList.vue';
@@ -284,9 +292,19 @@ export default {
284292
ActiveTab,
285293
router,
286294
route,
295+
parsedToken,
287296
};
288297
},
289298
watch: {
299+
userRoles(newValue) {
300+
adminRoles.value = newValue.map((role: { name: string }) => role.name);
301+
fetchWrapper(PERMISSIONS_ENDPOINT, 'GET').then(
302+
(response) =>
303+
(permissions.value = response.data.filter(
304+
(p: Permission) => p.user_id == this.parsedToken.sub,
305+
)),
306+
);
307+
},
290308
loggedIn(newValue: boolean) {
291309
if (!newValue) {
292310
window.location.href = LOGIN_PATH;

0 commit comments

Comments
 (0)