Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First pass at MVP single-user syncing backend #7985

Merged
merged 4 commits into from
Apr 26, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
120 changes: 108 additions & 12 deletions kolibri/core/auth/management/commands/sync.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import math
import re
from contextlib import contextmanager

from django.core.management import call_command
Expand All @@ -14,6 +15,8 @@
from ..utils import get_baseurl
from ..utils import get_client_and_server_certs
from ..utils import get_dataset_id
from ..utils import get_single_user_sync_filter
from ..utils import provision_single_user_device
from kolibri.core.auth.constants.morango_sync import PROFILE_FACILITY_DATA
from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
from kolibri.core.auth.constants.morango_sync import State
Expand Down Expand Up @@ -59,12 +62,17 @@ def add_arguments(self, parser):
parser.add_argument(
"--username",
type=str,
help="username of superuser on server we are syncing with",
help="username of superuser or facility admin on server we are syncing with",
)
parser.add_argument(
"--password",
type=str,
help="password of superuser on server we are syncing with",
help="password of superuser or facility admin on server we are syncing with",
)
parser.add_argument(
"--user-id",
rtibbles marked this conversation as resolved.
Show resolved Hide resolved
type=str,
help="for single-user syncing, the user ID of the account to be synced",
)
parser.add_argument(
"--no-provision",
Expand All @@ -81,6 +89,7 @@ def handle_async(self, *args, **options): # noqa C901
chunk_size,
username,
password,
user_id,
no_push,
no_pull,
noninteractive,
Expand All @@ -91,6 +100,7 @@ def handle_async(self, *args, **options): # noqa C901
options["chunk_size"],
options["username"],
options["password"],
options["user_id"],
options["no_push"],
options["no_pull"],
options["noninteractive"],
Expand Down Expand Up @@ -122,7 +132,36 @@ def handle_async(self, *args, **options): # noqa C901
"Device can not sync with itself. Please recheck base URL and try again."
)

if PORTAL_SYNC: # do portal sync setup
if user_id: # it's a single-user sync

if not facility_id:
raise CommandError(
"Facility ID must be specified in order to do single-user syncing"
)
if not re.match("[a-f0-9]{32}", user_id):
raise CommandError("User ID must be a 32-character UUID (no dashes)")

dataset_id = get_dataset_id(
baseurl, identifier=facility_id, noninteractive=True
)

client_cert, server_cert, username = get_client_and_server_certs(
username,
password,
dataset_id,
network_connection,
user_id=user_id,
noninteractive=noninteractive,
)

scopes = [client_cert.scope_definition_id, server_cert.scope_definition_id]

if len(set(scopes)) != 2:
raise CommandError(
"To do a single-user sync, one device must have a single-user certificate, and the other a full-facility certificate."
)

elif PORTAL_SYNC: # do portal sync setup
facility = get_facility(
facility_id=facility_id, noninteractive=noninteractive
)
Expand Down Expand Up @@ -181,16 +220,34 @@ def handle_async(self, *args, **options): # noqa C901
try:
# pull from server
if not no_pull:
self._handle_pull(sync_session_client, noninteractive, dataset_id)
self._handle_pull(
sync_session_client,
noninteractive,
dataset_id,
client_cert,
server_cert,
user_id=user_id,
)
# and push our own data to server
if not no_push:
self._handle_push(sync_session_client, noninteractive, dataset_id)
self._handle_push(
sync_session_client,
noninteractive,
dataset_id,
client_cert,
server_cert,
user_id=user_id,
)

if not no_provision:
with self._lock():
create_superuser_and_provision_device(
username, dataset_id, noninteractive=noninteractive
)
if user_id:
provision_single_user_device(user_id)
else:
create_superuser_and_provision_device(
username, dataset_id, noninteractive=noninteractive
)

except UserCancelledError:
if self.job:
self.job.extra_metadata.update(sync_state=State.CANCELLED)
Expand Down Expand Up @@ -224,7 +281,15 @@ def _raise_cancel(self, *args, **kwargs):
if self.is_cancelled() and (not self.job or self.job.cancellable):
raise UserCancelledError()

def _handle_pull(self, sync_session_client, noninteractive, dataset_id):
def _handle_pull(
self,
sync_session_client,
noninteractive,
dataset_id,
client_cert,
server_cert,
user_id,
):
"""
:type sync_session_client: morango.sync.syncsession.SyncSessionClient
:type noninteractive: bool
Expand Down Expand Up @@ -259,12 +324,32 @@ def _handle_pull(self, sync_session_client, noninteractive, dataset_id):
"Completed pull transfer session",
)

sync_client.initialize(Filter(dataset_id))
if not user_id:
# full-facility sync
sync_client.initialize(Filter(dataset_id))
else:
# single-user sync
client_is_single_user = (
client_cert.scope_definition_id == ScopeDefinitions.SINGLE_USER
)
filt = get_single_user_sync_filter(
dataset_id, user_id, is_read=client_is_single_user
)
sync_client.initialize(Filter(filt))

sync_client.run()
with self._lock():
sync_client.finalize()

def _handle_push(self, sync_session_client, noninteractive, dataset_id):
def _handle_push(
self,
sync_session_client,
noninteractive,
dataset_id,
client_cert,
server_cert,
user_id,
):
"""
:type sync_session_client: morango.sync.syncsession.SyncSessionClient
:type noninteractive: bool
Expand Down Expand Up @@ -299,7 +384,18 @@ def _handle_push(self, sync_session_client, noninteractive, dataset_id):
)

with self._lock():
sync_client.initialize(Filter(dataset_id))
if not user_id:
# full-facility sync
sync_client.initialize(Filter(dataset_id))
else:
# single-user sync
client_is_single_user = (
client_cert.scope_definition_id == ScopeDefinitions.SINGLE_USER
)
filt = get_single_user_sync_filter(
dataset_id, user_id, is_read=not client_is_single_user
)
sync_client.initialize(Filter(filt))

sync_client.run()

Expand Down
86 changes: 71 additions & 15 deletions kolibri/core/auth/management/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.urls import reverse
from django.utils.six.moves import input
from morango.models import Certificate
from morango.models import ScopeDefinition
from six.moves.urllib.parse import urljoin

from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
Expand Down Expand Up @@ -159,28 +160,61 @@ def get_baseurl(baseurl):


def get_client_and_server_certs(
username, password, dataset_id, nc, noninteractive=False
username, password, dataset_id, nc, user_id=None, noninteractive=False
):
# get servers certificates which server has a private key for
server_certs = nc.get_remote_certificates(
dataset_id, scope_def_id=ScopeDefinitions.FULL_FACILITY
)
if not server_certs:
raise CommandError(
"Server does not have any certificates for dataset_id: {}".format(
dataset_id
)
)
server_cert = server_certs[0]

# check for the certs we own for the specific facility
# get any full-facility certificates we have for the facility
owned_certs = (
Certificate.objects.filter(id=dataset_id)
.get_descendants(include_self=True)
.filter(scope_definition_id=ScopeDefinitions.FULL_FACILITY)
.exclude(_private_key=None)
)

if not user_id: # it's a full-facility sync

csr_scope_params = {"dataset_id": dataset_id}

client_scope = ScopeDefinitions.FULL_FACILITY
server_scope = ScopeDefinitions.FULL_FACILITY

else: # it's a single-user sync

csr_scope_params = {"dataset_id": dataset_id, "user_id": user_id}

if owned_certs:
# client is the one with a full-facility cert
client_scope = ScopeDefinitions.FULL_FACILITY
server_scope = ScopeDefinitions.SINGLE_USER
else:
# server must be the one with the full-facility cert
client_scope = ScopeDefinitions.SINGLE_USER
server_scope = ScopeDefinitions.FULL_FACILITY

# check for certs we own for the specific user_id for single-user syncing
owned_certs = (
Certificate.objects.filter(id=dataset_id)
.get_descendants(include_self=True)
.filter(scope_definition_id=ScopeDefinitions.SINGLE_USER)
.filter(scope_params__contains=user_id)
.exclude(_private_key=None)
)

# get server certificates that server has a private key for
server_certs = nc.get_remote_certificates(dataset_id, scope_def_id=server_scope)

# filter down to the single-user certificates for this specific user, if needed
if server_scope == ScopeDefinitions.SINGLE_USER:
server_certs = [cert for cert in server_certs if user_id in cert.scope_params]

if not server_certs:
raise CommandError(
"Server does not have needed certificate with scope '{}'".format(
server_scope
)
)
server_cert = server_certs[0]

# if we don't own any certs, do a csr request
if not owned_certs:

Expand All @@ -194,8 +228,8 @@ def get_client_and_server_certs(

client_cert = nc.certificate_signing_request(
server_cert,
ScopeDefinitions.FULL_FACILITY,
{"dataset_id": dataset_id},
client_scope,
csr_scope_params,
userargs=username,
password=password,
)
Expand Down Expand Up @@ -249,6 +283,28 @@ def create_superuser_and_provision_device(username, dataset_id, noninteractive=F
)


def provision_single_user_device(user_id):

user = FacilityUser.objects.get(id=user_id)

# if device has not been provisioned, set it up
if not device_provisioned():
provision_device(default_facility=user.facility)

DevicePermissions.objects.get_or_create(
user=user, defaults={"is_superuser": False, "can_manage_content": True}
)


def get_single_user_sync_filter(dataset_id, user_id, is_read):
scopedef = ScopeDefinition.objects.get(id=ScopeDefinitions.SINGLE_USER)
scope = scopedef.get_scope({"dataset_id": dataset_id, "user_id": user_id})
if is_read:
return str(scope.read_filter)
else:
return str(scope.write_filter)


def run_once(f):
"""
Runs a function once, useful for connection once to a signal
Expand Down