Skip to content

Commit 92888d6

Browse files
committed
Manage no active superusers in LOD devices
1 parent 963a961 commit 92888d6

File tree

11 files changed

+272
-23
lines changed

11 files changed

+272
-23
lines changed

kolibri/core/auth/management/commands/deprovision.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from kolibri.core.auth.management.utils import confirm_or_exit
1212
from kolibri.core.auth.models import FacilityDataset
1313
from kolibri.core.auth.models import FacilityUser
14-
from kolibri.core.auth.utils.delete import DisablePostDeleteSignal
14+
from kolibri.core.auth.utils.deprovision import deprovision
15+
from kolibri.core.auth.utils.deprovision import get_deprovision_progress_total
1516
from kolibri.core.device.models import DevicePermissions
1617
from kolibri.core.device.models import DeviceSettings
1718
from kolibri.core.logger.models import AttemptLog
@@ -57,12 +58,10 @@ def add_arguments(self, parser):
5758
)
5859

5960
def deprovision(self):
60-
with DisablePostDeleteSignal(), self.start_progress(
61-
total=len(MODELS_TO_DELETE)
61+
with self.start_progress(
62+
total=get_deprovision_progress_total()
6263
) as progress_update:
63-
for Model in MODELS_TO_DELETE:
64-
Model.objects.all().delete()
65-
progress_update(1)
64+
deprovision(progress_update=progress_update)
6665

6766
def handle_async(self, *args, **options):
6867

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from morango.models import Certificate
2+
from morango.models import DatabaseIDModel
3+
from morango.models import DatabaseMaxCounter
4+
from morango.models import DeletedModels
5+
from morango.models import HardDeletedModels
6+
from morango.models import Store
7+
8+
from kolibri.core.auth.models import FacilityDataset
9+
from kolibri.core.auth.models import FacilityUser
10+
from kolibri.core.auth.utils.delete import DisablePostDeleteSignal
11+
from kolibri.core.device.models import DevicePermissions
12+
from kolibri.core.device.models import DeviceSettings
13+
from kolibri.core.device.utils import reset_device_provisioned_flag
14+
from kolibri.core.logger.models import AttemptLog
15+
from kolibri.core.logger.models import ContentSessionLog
16+
from kolibri.core.logger.models import ContentSummaryLog
17+
18+
MODELS_TO_DELETE = [
19+
AttemptLog,
20+
ContentSessionLog,
21+
ContentSummaryLog,
22+
FacilityUser,
23+
FacilityDataset,
24+
HardDeletedModels,
25+
Certificate,
26+
DatabaseIDModel,
27+
Store,
28+
DevicePermissions,
29+
DeletedModels,
30+
DeviceSettings,
31+
DatabaseMaxCounter,
32+
]
33+
34+
35+
def deprovision(progress_update=None):
36+
with DisablePostDeleteSignal():
37+
for Model in MODELS_TO_DELETE:
38+
Model.objects.all().delete()
39+
if progress_update:
40+
progress_update(1)
41+
reset_device_provisioned_flag()
42+
43+
44+
def get_deprovision_progress_total():
45+
return len(MODELS_TO_DELETE)

kolibri/core/device/tasks.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from kolibri.core.auth.models import Facility
1111
from kolibri.core.auth.models import FacilityUser
1212
from kolibri.core.auth.serializers import FacilitySerializer
13+
from kolibri.core.auth.utils.deprovision import deprovision
1314
from kolibri.core.device.models import DevicePermissions
1415
from kolibri.core.device.models import OSUser
1516
from kolibri.core.device.serializers import DeviceSerializerMixin
@@ -20,6 +21,7 @@
2021
from kolibri.core.device.utils import valid_app_key_on_request
2122
from kolibri.core.tasks.decorators import register_task
2223
from kolibri.core.tasks.permissions import FirstProvisioning
24+
from kolibri.core.tasks.permissions import IsDeviceUnusable
2325
from kolibri.core.tasks.utils import get_current_job
2426
from kolibri.core.tasks.validation import JobValidator
2527
from kolibri.plugins.app.utils import GET_OS_USER
@@ -28,6 +30,7 @@
2830
logger = logging.getLogger(__name__)
2931

3032
PROVISION_TASK_QUEUE = "device_provision"
33+
DEPROVISION_TASK_QUEUE = "device_deprovision"
3134

3235

3336
class DeviceProvisionValidator(DeviceSerializerMixin, JobValidator):
@@ -227,3 +230,15 @@ def provisiondevice(**data): # noqa C901
227230
facility_id=facility.id,
228231
username=superuser.username if superuser else None,
229232
)
233+
234+
235+
@register_task(
236+
permission_classes=[IsDeviceUnusable],
237+
cancellable=False,
238+
queue=DEPROVISION_TASK_QUEUE,
239+
)
240+
def deprovisiondevice():
241+
"""
242+
Task for deprovisioning a device.
243+
"""
244+
deprovision()

kolibri/core/device/utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
APP_KEY_COOKIE_NAME = "app_key_cookie"
3030
APP_AUTH_TOKEN_COOKIE_NAME = "app_auth_token_cookie"
3131

32+
DEVICE_UNUSABLE_NO_SUPERUSERS = "NO_SUPERUSERS"
33+
DEVICE_UNUSABLE_SUPERUSERS_SOFT_DELETED = "SUPERUSERS_SOFT_DELETED"
34+
3235

3336
class DeviceNotProvisioned(Exception):
3437
pass
@@ -494,3 +497,37 @@ def is_full_facility_import(dataset_id):
494497
.filter(scope_definition_id=ScopeDefinitions.FULL_FACILITY)
495498
.exists()
496499
)
500+
501+
502+
device_is_provisioned = False
503+
504+
505+
def is_provisioned():
506+
# First check if the device has been provisioned
507+
global device_is_provisioned
508+
device_is_provisioned = device_is_provisioned or device_provisioned()
509+
return device_is_provisioned
510+
511+
512+
def reset_device_provisioned_flag():
513+
global device_is_provisioned
514+
device_is_provisioned = False
515+
516+
517+
def get_device_unusable_reason():
518+
from kolibri.core.auth.models import FacilityUser
519+
520+
is_soud = get_device_setting("subset_of_users_device")
521+
if not is_soud:
522+
return None
523+
524+
superadmins = FacilityUser.all_objects.filter(devicepermissions__is_superuser=True)
525+
if not superadmins.exists():
526+
return DEVICE_UNUSABLE_NO_SUPERUSERS
527+
528+
non_soft_deleted_superadmins = FacilityUser.objects.filter(
529+
devicepermissions__is_superuser=True
530+
)
531+
if not non_soft_deleted_superadmins.exists():
532+
return DEVICE_UNUSABLE_SUPERUSERS_SOFT_DELETED
533+
return None

kolibri/core/tasks/permissions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,13 @@ def user_can_run_job(self, user, job):
197197

198198
def user_can_read_job(self, user, job):
199199
return True
200+
201+
202+
class IsDeviceUnusable(BasePermission):
203+
def user_can_run_job(self, user, job):
204+
from kolibri.core.device.utils import get_device_unusable_reason
205+
206+
return get_device_unusable_reason() is not None
207+
208+
def user_can_read_job(self, user, job):
209+
return True

kolibri/core/views.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from kolibri.core.device.translation import get_device_language
2929
from kolibri.core.device.translation import get_settings_language
3030
from kolibri.core.device.utils import allow_guest_access
31-
from kolibri.core.device.utils import device_provisioned
31+
from kolibri.core.device.utils import is_provisioned
3232
from kolibri.core.hooks import LogoutRedirectHook
3333
from kolibri.core.hooks import RoleBasedRedirectHook
3434
from kolibri.core.theme_hook import ThemeHook
@@ -128,16 +128,6 @@ def get(self, request):
128128
return RootURLRedirectView.as_view()(request)
129129

130130

131-
device_is_provisioned = False
132-
133-
134-
def is_provisioned():
135-
# First check if the device has been provisioned
136-
global device_is_provisioned
137-
device_is_provisioned = device_is_provisioned or device_provisioned()
138-
return device_is_provisioned
139-
140-
141131
class RootURLRedirectView(View):
142132
def get(self, request):
143133
"""

kolibri/plugins/user_auth/assets/src/constants.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ export const ComponentMap = {
99
export const pageNameToModuleMap = {
1010
[ComponentMap.SIGN_IN]: 'signIn',
1111
};
12+
13+
export const DeviceUnusableReason = {
14+
NO_SUPERUSERS: 'NO_SUPERUSERS',
15+
SUPERUSERS_SOFT_DELETED: 'SUPERUSERS_SOFT_DELETED',
16+
};

kolibri/plugins/user_auth/assets/src/views/AuthBase.vue

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<div class="main-cell table-cell">
77
<!-- remote access disabled -->
88
<div
9-
v-if="!$store.getters.allowAccess"
9+
v-if="!$store.getters.allowAccess || deviceUnusableReason"
1010
class="box"
1111
:style="{ backgroundColor: $themeTokens.surface }"
1212
>
@@ -24,10 +24,16 @@
2424
>
2525
{{ logoText }}
2626
</h1>
27-
<p data-test="restrictedAccess">
28-
{{ $tr('restrictedAccess') }}
29-
</p>
30-
<p>{{ $tr('restrictedAccessDescription') }}</p>
27+
<template v-if="!$store.getters.allowAccess">
28+
<p data-test="restrictedAccess">
29+
{{ $tr('restrictedAccess') }}
30+
</p>
31+
<p>{{ $tr('restrictedAccessDescription') }}</p>
32+
</template>
33+
<DeviceUnusableMessage
34+
v-else
35+
:reason="deviceUnusableReason"
36+
/>
3137
</div>
3238
<!-- remote access enabled -->
3339
<div
@@ -191,10 +197,11 @@
191197
import LanguageSwitcherFooter from '../views/LanguageSwitcherFooter';
192198
import commonUserStrings from './commonUserStrings';
193199
import getUrlParameter from './getUrlParameter';
200+
import DeviceUnusableMessage from './DeviceUnusableMessage.vue';
194201
195202
export default {
196203
name: 'AuthBase',
197-
components: { CoreLogo, LanguageSwitcherFooter, PrivacyInfoModal },
204+
components: { CoreLogo, LanguageSwitcherFooter, PrivacyInfoModal, DeviceUnusableMessage },
198205
mixins: [commonCoreStrings, commonUserStrings],
199206
setup() {
200207
const { facilityConfig } = useFacilities();
@@ -271,6 +278,9 @@
271278
showGuestAccess() {
272279
return plugin_data.allowGuestAccess && !this.oidcProviderFlow;
273280
},
281+
deviceUnusableReason() {
282+
return plugin_data.deviceUnusableReason;
283+
},
274284
versionMsg() {
275285
return this.$tr('poweredBy', { version: __version });
276286
},
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<template>
2+
3+
<div>
4+
<template v-if="reason === DeviceUnusableReason.NO_SUPERUSERS">
5+
<p>{{ strings.noSuperusersNotice$() }}</p>
6+
<p>{{ strings.noSuperuserCallToAction$() }}</p>
7+
</template>
8+
<template v-else-if="reason === DeviceUnusableReason.SUPERUSERS_SOFT_DELETED">
9+
<p>
10+
{{ strings.superusersSoftDeletedNotice$() }}
11+
</p>
12+
<p>
13+
{{ strings.superusersSoftDeletedCallToAction$() }}
14+
</p>
15+
</template>
16+
<p v-else>{{ strings.unknownIssueNotice$() }}</p>
17+
<KButton
18+
:disabled="taskLoading"
19+
style="margin-top: 16px"
20+
primary
21+
:text="strings.reinstallKolibriAction$()"
22+
@click="deprovision"
23+
/>
24+
</div>
25+
26+
</template>
27+
28+
29+
<script>
30+
31+
import TaskResource from 'kolibri/apiResources/TaskResource';
32+
import useTaskPolling from 'kolibri-common/composables/useTaskPolling';
33+
import useSnackbar from 'kolibri/composables/useSnackbar';
34+
import { TaskStatuses, TaskTypes } from 'kolibri-common/utils/syncTaskUtils';
35+
import redirectBrowser from 'kolibri/utils/redirectBrowser';
36+
import { createTranslator } from 'kolibri/utils/i18n';
37+
38+
import { computed, ref, watch } from 'vue';
39+
import { DeviceUnusableReason } from '../constants';
40+
41+
const DEPROVISION_TASK_QUEUE = 'device_deprovision';
42+
43+
const strings = createTranslator('DeviceUnusableStrings', {
44+
noSuperusersNotice: {
45+
message: 'This device is unusable because there are no superuser accounts on this device.',
46+
context: 'Notice that there are no superuser accounts on the device',
47+
},
48+
noSuperuserCallToAction: {
49+
message: 'Please reinstall Kolibri to create a superuser account.',
50+
context: 'Call to action to reinstall Kolibri to create a superuser account',
51+
},
52+
superusersSoftDeletedNotice: {
53+
message:
54+
'This device is unusable because all superuser accounts on this device have been deleted on the server.',
55+
context: 'Notice that all superuser accounts have been deleted on the server',
56+
},
57+
superusersSoftDeletedCallToAction: {
58+
message:
59+
'Please contact your system administrator to restore your account or reinstall Kolibri to create a new superuser account.',
60+
context:
61+
'Call to action to contact system administrator or reinstall Kolibri to create a new superuser account',
62+
},
63+
unknownIssueNotice: {
64+
message: 'This device is unusable due to an unknown reason. Please contact support.',
65+
context: 'Notice that the device is unusable due to an unknown reason',
66+
},
67+
reinstallKolibriAction: {
68+
message: 'Reinstall Kolibri',
69+
context: 'Button text to reinstall Kolibri',
70+
},
71+
deprovisioningError: {
72+
message: 'An error occurred while trying to reinstall Kolibri. Please try again.',
73+
context: 'Error message when there is an error deprovisioning the device',
74+
},
75+
});
76+
77+
export default {
78+
name: 'DeviceUnusableMessage',
79+
setup() {
80+
const taskLoading = ref(false);
81+
const { tasks } = useTaskPolling(DEPROVISION_TASK_QUEUE);
82+
const { createSnackbar } = useSnackbar();
83+
const deprovisionTask = computed(() => tasks.value[tasks.value.length - 1]);
84+
85+
const deprovision = async () => {
86+
try {
87+
taskLoading.value = true;
88+
await TaskResource.startTask({
89+
type: TaskTypes.DEPROVISIONDEVICE,
90+
});
91+
} catch (e) {
92+
createSnackbar(strings.deprovisioningError$());
93+
}
94+
};
95+
96+
const clearTasks = async () => {
97+
try {
98+
await TaskResource.clearAll(DEPROVISION_TASK_QUEUE);
99+
} catch (e) {
100+
return;
101+
}
102+
};
103+
104+
watch(deprovisionTask, task => {
105+
if (!task) return;
106+
taskLoading.value = true;
107+
if (task.status === TaskStatuses.FAILED) {
108+
taskLoading.value = false;
109+
createSnackbar(strings.deprovisioningError$());
110+
clearTasks();
111+
return;
112+
}
113+
if (task.status === TaskStatuses.COMPLETED) {
114+
clearTasks();
115+
redirectBrowser();
116+
}
117+
});
118+
119+
return {
120+
strings,
121+
taskLoading,
122+
DeviceUnusableReason,
123+
deprovision,
124+
};
125+
},
126+
props: {
127+
reason: {
128+
type: String,
129+
required: true,
130+
validator: value => Object.values(DeviceUnusableReason).includes(value),
131+
},
132+
},
133+
};
134+
135+
</script>

0 commit comments

Comments
 (0)