Skip to content

Commit 311f38e

Browse files
authored
feat: management command allowing you to revoke certificates (#2652)
this allows revocation of certificates. It defaults to program_certificates, which is the most likely use case, but because some UserCredentials might not be synchronized from the LMS, does allow override of the credential type. * Adds a new model and django admin so this script can be run in an automated fashion * this required bringing in config_models FIXES: APER-3799
1 parent 62fdf3d commit 311f38e

File tree

14 files changed

+524
-25
lines changed

14 files changed

+524
-25
lines changed

credentials/apps/credentials/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from config_models.admin import ConfigurationModelAdmin
12
from django.contrib import admin
23
from django.db.models import Q
34

@@ -6,6 +7,7 @@
67
CourseCertificate,
78
ProgramCertificate,
89
ProgramCompletionEmailConfiguration,
10+
RevokeCertificatesConfig,
911
Signatory,
1012
UserCredential,
1113
UserCredentialAttribute,
@@ -113,3 +115,8 @@ class SignatoryAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin):
113115
class ProgramCompletionEmailConfigurationAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin):
114116
list_display = ("identifier", "enabled")
115117
search_fields = ("identifier",)
118+
119+
120+
@admin.register(RevokeCertificatesConfig)
121+
class RevokeCertificatesConfigAdmin(ConfigurationModelAdmin):
122+
pass
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Management command to revoke certificates given a certificate ID and a list of users"""
2+
3+
import logging
4+
import shlex
5+
from typing import TYPE_CHECKING, Any
6+
7+
from django.contrib.auth import get_user_model
8+
from django.core.management.base import BaseCommand, CommandError
9+
10+
from credentials.apps.credentials.models import RevokeCertificatesConfig, UserCredential
11+
12+
13+
if TYPE_CHECKING:
14+
from argparse import ArgumentParser
15+
16+
from django.db.models import QuerySet
17+
18+
19+
logger = logging.getLogger(__name__)
20+
User = get_user_model()
21+
22+
23+
class Command(BaseCommand):
24+
"""
25+
Management command to revoke certificates.
26+
27+
Given a certificate ID and a list of users, revoke that certificate ID
28+
for those users.
29+
30+
Example usage:
31+
32+
$ ./manage.py revoke_certificates --lms_user_ids 867 5309 925 --credential_id 90210
33+
"""
34+
35+
help = "Revoke certificates for a list of LMS user IDs. Defaults to program certificates."
36+
37+
def add_arguments(self, parser: "ArgumentParser") -> None:
38+
"""Arguments for the command."""
39+
parser.add_argument(
40+
"--dry-run",
41+
action="store_true",
42+
help="Just show a preview of what would happen.",
43+
)
44+
parser.add_argument(
45+
"--args-from-database",
46+
action="store_true",
47+
help="Use arguments from the RevokeCertificates model instead of the command line.",
48+
)
49+
parser.add_argument(
50+
"--verbose",
51+
action="store_true",
52+
help="log each update",
53+
)
54+
parser.add_argument(
55+
"--lms_user_ids",
56+
default=None,
57+
nargs="+",
58+
help="Users for whom this certificate should be revoked. Required.",
59+
)
60+
parser.add_argument(
61+
"--credential_id",
62+
default=None,
63+
help="ID of the certificate to be revoked. Required.",
64+
)
65+
parser.add_argument(
66+
"--credential_type",
67+
default="programcertificate",
68+
choices=["coursecertificate", "programcertificate", "credlybadgetemplate"],
69+
help="Type of credential to revoke. Defaults to 'programcertificate'",
70+
)
71+
72+
def get_usernames_from_lms_user_ids(self, lms_user_ids: list[str]) -> "QuerySet":
73+
"""
74+
Generate Users from a list of usernames from a list of user IDs
75+
76+
Because a UserCredential stores a username, not a foreign key, it's most
77+
efficient to convert the list of user IDs to users directly, before
78+
starting the query. Returning a QuerySet of the User objects (instead of
79+
usernames) allows us to do verbose logging and error reporting.
80+
81+
Arguments:
82+
83+
lms_user_ids: list(str): a list of LMS user IDs
84+
85+
Returns:
86+
87+
a QuerySet of User objects.
88+
"""
89+
users = User.objects.filter(lms_user_id__in=lms_user_ids)
90+
missing_users = set(lms_user_ids).difference({str(i.lms_user_id) for i in users})
91+
if missing_users:
92+
logger.warning(f"The following user IDs don't match existing users: {missing_users}")
93+
return users
94+
95+
def get_args_from_database(self) -> dict[str, Any]:
96+
"""Returns an options dictionary from the current NotifyCredentialsConfig model."""
97+
config = RevokeCertificatesConfig.current()
98+
if not config.enabled:
99+
raise CommandError("RevokeCertificatesConfig is disabled, but --args-from-database was requested.")
100+
101+
argv = shlex.split(config.arguments)
102+
parser = self.create_parser("manage.py", "revoke_certificates")
103+
return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object
104+
105+
def handle(self, *args, **options):
106+
if options["args_from_database"]:
107+
options = self.get_args_from_database()
108+
credential_id = options.get("credential_id")
109+
verbosity = options.get("verbose")
110+
credential_type = options.get("credential_type")
111+
dry_run = options.get("dry_run")
112+
lms_user_ids = options.get("lms_user_ids")
113+
114+
logger.info(
115+
f"revoke_certificates starting, dry-run={dry_run}, credential_id={credential_id}, "
116+
f"credential_type={credential_type}, lms_user_ids={lms_user_ids}, verbosity={verbosity}"
117+
)
118+
119+
# Because we allow args_from_database, we cannot rely on marking arguments as required,
120+
# so we validate our arguments here.
121+
if not credential_id:
122+
raise CommandError("You must specify a credential_id")
123+
if not lms_user_ids:
124+
raise CommandError("You must specify list of lms_user_ids")
125+
users = self.get_usernames_from_lms_user_ids(lms_user_ids)
126+
if not users:
127+
raise CommandError("None of the given lms_user_ids maps to a real user")
128+
129+
# We use usernames here, not foreign keys, so just make a list.
130+
# This is not going to be a huge set of users, run from a management command.
131+
usernames = [i.username for i in users] # type: list[str]
132+
133+
user_creds_to_revoke = UserCredential.objects.filter(
134+
username__in=usernames,
135+
status=UserCredential.AWARDED,
136+
credential_content_type__model=credential_type,
137+
credential_id=credential_id,
138+
)
139+
if not user_creds_to_revoke:
140+
raise CommandError("No active certificates match the given criteria")
141+
142+
# as a manually input list, this should be small enough to do in a single bulk_update
143+
for user_cred in user_creds_to_revoke:
144+
if verbosity:
145+
# It's not worth doing an extra query to annotate the verbose logging message with
146+
# user ID, and username isn't PII safe. If the person reading the logs wants more
147+
# info about the affected users, this log message includes enough to look them up.
148+
logger.info(f"Revoking UserCredential {user_cred.id} ({credential_type} {credential_id})")
149+
user_cred.status = UserCredential.REVOKED
150+
if not dry_run:
151+
user_creds_to_revoke.bulk_update(user_creds_to_revoke, ["status"])
152+
153+
logger.info("Done revoking certificates")

0 commit comments

Comments
 (0)