Skip to content

Commit

Permalink
Issue 5770 - RFE - Extend Password Adminstrators to allow skipping pa…
Browse files Browse the repository at this point in the history
…ssword info updates

Description:

Add new config setting to state that password admin updates should not update
entry's password state attributes.

relates: 389ds#5770

Reviewed by: progier, tbordaz, spichugi (Thanks!)
  • Loading branch information
mreynolds389 committed May 17, 2023
1 parent 5304d4f commit 9908c98
Show file tree
Hide file tree
Showing 14 changed files with 291 additions and 61 deletions.
88 changes: 84 additions & 4 deletions dirsrvtests/tests/suites/password/pwdAdmin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ def password_policy(topology_st):
})

# Add Password Admin 2

admin2_user = users.create(properties={
'uid': 'admin2',
'cn' : 'admin2',
Expand All @@ -72,7 +71,6 @@ def password_policy(topology_st):
})

# Add Password Admin Group

admin_group = groups.create(properties={
'cn': 'password admin group'
})
Expand All @@ -81,7 +79,6 @@ def password_policy(topology_st):
admin_group.add_member(admin2_user.dn)

# Configure password policy

log.info('test_pwdAdmin_init: Configuring password policy...')

topology_st.standalone.config.replace_many(
Expand All @@ -91,7 +88,10 @@ def password_policy(topology_st):
('passwordMinTokenLength', '2'),
('passwordExp', 'on'),
('passwordMinDigits', '1'),
('passwordMinSpecials', '1')
('passwordMinSpecials', '1'),
('passwordHistory', 'on'),
('passwordStorageScheme', 'clear'),
('nsslapd-enable-upgrade-hash', 'off')
)

#
Expand Down Expand Up @@ -140,6 +140,7 @@ def password_policy(topology_st):

return (admin_group, admin1_user, admin2_user)


def test_pwdAdmin_bypass(topology_st, password_policy):
"""Test that password administrators/root DN can
bypass password syntax/policy
Expand Down Expand Up @@ -288,6 +289,7 @@ def test_pwdAdmin_modify(topology_st, password_policy):
for passwd in INVALID_PWDS:
u3.replace('userPassword', passwd)


def test_pwdAdmin_group(topology_st, password_policy):
"""Test that password admin group can bypass policy.
Expand Down Expand Up @@ -356,6 +358,84 @@ def test_pwdAdmin_config_validation(topology_st, password_policy):
with pytest.raises(ldap.INVALID_SYNTAX):
topology_st.standalone.config.set('passwordAdminDN', 'zzzzzzzzzzzz')


def test_pwd_admin_config_test_skip_updates(topology_st, password_policy):
"""Check passwordAdminDN does not update entry password state attributes
:id: 964f1430-795b-4f4d-85b2-abaffe66ddcb
:setup: Standalone instance
:steps:
1. Add test entry
2. Update password
3. Password history updated
4. Enable "skip info update"
5. Update password again
6. New password not in history
:expectedresults:
1. Success
2. Success
3. Success
4. Success
5. Success
6. Success
"""

inst = topology_st.standalone
passwd_in_history = "Secret123"
password_not_in_history = "ShouldNotBeInHistory"
(admin_group, admin1_user, admin2_user) = password_policy

# Update config
inst.config.set('passwordAdminDN', admin_group.dn)

# Add test entry
admin_conn = admin1_user.bind(ADMIN_PWD)
admin_users = UserAccounts(admin_conn, DEFAULT_SUFFIX)
admin_users.create(properties={
'uid': 'skipInfoUpdate',
'cn': 'skipInfoUpdate',
'sn': 'skipInfoUpdate',
'uidNumber': '1001',
'gidNumber': '2002',
'homeDirectory': '/home/skipInfoUpdate',
'userPassword': "abdcefghijk"
})

# Update password to populate history
user = admin_users.get('skipInfoUpdate')
user.replace('userPassword', passwd_in_history)
user.replace('userPassword', passwd_in_history)
time.sleep(1)

# Check password history was updated
passwords = user.get_attr_vals_utf8('passwordHistory')
log.debug(f"passwords in history for {user.dn}: {str(passwords)}")
found = False
for passwd in passwords:
if passwd_in_history in passwd:
found = True
assert found

# Disable password state info updates
inst.config.set('passwordAdminSkipInfoUpdate', 'on')
time.sleep(1)

# Update password
user.replace('userPassword', password_not_in_history)
user.replace('userPassword', password_not_in_history)
time.sleep(1)

# Check it is not in password history
passwords = user.get_attr_vals_utf8('passwordHistory')
log.debug(f"Part 2: passwords in history for {user.dn}: {str(passwords)}")
found = False
for passwd in passwords:
if password_not_in_history in passwd:
found = True
assert not found


if __name__ == '__main__':
# Run isolated
# -s for DEBUG mode
Expand Down
3 changes: 2 additions & 1 deletion ldap/schema/02common.ldif
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ attributeTypes: ( 2.16.840.1.113730.3.1.2358 NAME 'nsTaskTotalItems' DESC 'Slapi
attributeTypes: ( 2.16.840.1.113730.3.1.2359 NAME 'nsTaskCreated' DESC 'Slapi Task creation date' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2375 NAME 'nsTaskWarning' DESC 'Slapi Task warning code' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2389 NAME 'winSyncFlattenTree' DESC 'When set to on, will flatten tree structure in AD replication' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN '389 Directory Server' )
attributeTypes: ( 2.16.840.1.113730.3.1.2395 NAME ( 'passwordAdminSkipInfoUpdate' 'pwdAdminSkipInfoUpdate' ) DESC 'Netscape defined password policy attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN 'Netscape Directory Server' )
#
# objectclasses:
#
Expand All @@ -167,7 +168,7 @@ objectClasses: ( 2.16.840.1.113730.3.2.7 NAME 'nsLicenseUser' DESC 'Netscape def
objectClasses: ( 2.16.840.1.113730.3.2.1 NAME 'changeLogEntry' DESC 'LDAP changelog objectclass' SUP top MUST ( targetdn $ changeTime $ changenumber $ changeType ) MAY ( changes $ newrdn $ deleteoldrdn $ newsuperior ) X-ORIGIN 'Changelog Internet Draft' )
objectClasses: ( 2.16.840.1.113730.3.2.6 NAME 'referral' DESC 'LDAP referrals objectclass' SUP top MAY ( ref ) X-ORIGIN 'LDAPv3 referrals Internet Draft' )
objectClasses: ( 2.16.840.1.113730.3.2.12 NAME 'passwordObject' DESC 'Netscape defined password policy objectclass' SUP top MAY ( pwdpolicysubentry $ passwordExpirationTime $ passwordExpWarned $ passwordRetryCount $ retryCountResetTime $ accountUnlockTime $ passwordHistory $ passwordAllowChangeTime $ passwordGraceUserTime $ pwdReset $ pwdTPRReset $ pwdTPRUseCount $ pwdTPRValidFrom $ pwdTPRExpireAt ) X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.13 NAME 'passwordPolicy' DESC 'Netscape defined password policy objectclass' SUP top MAY ( passwordMaxAge $ passwordExp $ passwordMinLength $ passwordKeepHistory $ passwordInHistory $ passwordChange $ passwordWarning $ passwordLockout $ passwordMaxFailure $ passwordResetDuration $ passwordUnlock $ passwordLockoutDuration $ passwordCheckSyntax $ passwordMustChange $ passwordStorageScheme $ passwordMinAge $ passwordResetFailureCount $ passwordGraceLimit $ passwordMinDigits $ passwordMinAlphas $ passwordMinUppers $ passwordMinLowers $ passwordMinSpecials $ passwordMin8bit $ passwordMaxRepeats $ passwordMinCategories $ passwordMinTokenLength $ passwordTrackUpdateTime $ passwordAdminDN $ passwordDictCheck $ passwordDictPath $ passwordPalindrome $ passwordMaxSequence $ passwordMaxClassChars $ passwordMaxSeqSets $ passwordBadWords $ passwordUserAttributes $ passwordSendExpiringTime $ passwordTPRMaxUse $ passwordTPRDelayExpireAt $ passwordTPRDelayValidFrom ) X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.13 NAME 'passwordPolicy' DESC 'Netscape defined password policy objectclass' SUP top MAY ( passwordMaxAge $ passwordExp $ passwordMinLength $ passwordKeepHistory $ passwordInHistory $ passwordChange $ passwordWarning $ passwordLockout $ passwordMaxFailure $ passwordResetDuration $ passwordUnlock $ passwordLockoutDuration $ passwordCheckSyntax $ passwordMustChange $ passwordStorageScheme $ passwordMinAge $ passwordResetFailureCount $ passwordGraceLimit $ passwordMinDigits $ passwordMinAlphas $ passwordMinUppers $ passwordMinLowers $ passwordMinSpecials $ passwordMin8bit $ passwordMaxRepeats $ passwordMinCategories $ passwordMinTokenLength $ passwordTrackUpdateTime $ passwordAdminDN $ passwordDictCheck $ passwordDictPath $ passwordPalindrome $ passwordMaxSequence $ passwordMaxClassChars $ passwordMaxSeqSets $ passwordBadWords $ passwordUserAttributes $ passwordSendExpiringTime $ passwordTPRMaxUse $ passwordTPRDelayExpireAt $ passwordTPRDelayValidFrom $ passwordAdminSkipInfoUpdate ) X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.30 NAME 'glue' DESC 'Netscape defined objectclass' SUP top X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.32 NAME 'netscapeMachineData' DESC 'Netscape defined objectclass' SUP top X-ORIGIN 'Netscape Directory Server' )
objectClasses: ( 2.16.840.1.113730.3.2.38 NAME 'vlvSearch' DESC 'Netscape defined objectclass' SUP top MUST ( cn $ vlvBase $ vlvScope $ vlvFilter ) MAY ( multiLineDescription ) X-ORIGIN 'Netscape Directory Server' )
Expand Down
2 changes: 1 addition & 1 deletion ldap/servers/slapd/add.c
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ op_shared_add(Slapi_PBlock *pb)
* Check password syntax, unless this is a pwd admin/rootDN
*/
present_values = attr_get_present_values(attr);
if (!pw_is_pwp_admin(pb, pwpolicy) &&
if (!pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN) &&
check_pw_syntax(pb, slapi_entry_get_sdn_const(e),
present_values, NULL, e, 0) != 0) {
/* error result is sent from check_pw_syntax */
Expand Down
31 changes: 30 additions & 1 deletion ldap/servers/slapd/libglobs.c
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ slapi_onoff_t init_enable_upgrade_hash;
slapi_special_filter_verify_t init_verify_filter_schema;
slapi_onoff_t init_enable_ldapssotoken;
slapi_onoff_t init_return_orig_dn;
slapi_onoff_t init_pw_admin_skip_info;

static int
isInt(ConfigVarType type)
Expand Down Expand Up @@ -420,6 +421,11 @@ static struct config_get_and_set
NULL, 0,
NULL,
CONFIG_STRING, (ConfigGetFunc)config_get_pw_admin_dn, "", NULL},
{CONFIG_PW_ADMIN_SKIP_INFO_ATTRIBUTE, config_set_pw_admin_skip_info,
NULL, 0,
(void **)&global_slapdFrontendConfig.pw_policy.pw_admin_skip_info,
CONFIG_ON_OFF, (ConfigGetFunc)config_get_pw_admin_dn,
&init_pw_admin_skip_info, NULL},
{CONFIG_ACCESSLOG_LOGROTATIONSYNCENABLED_ATTRIBUTE, NULL,
log_set_rotationsync_enabled, SLAPD_ACCESS_LOG,
(void **)&global_slapdFrontendConfig.accesslog_rotationsync_enabled,
Expand Down Expand Up @@ -1687,6 +1693,7 @@ pwpolicy_init_defaults (passwdPolicy *pw_policy)
pw_policy->pw_tpr_delay_valid_from = SLAPD_DEFAULT_PW_TPR_DELAY_VALID_FROM;
pw_policy->pw_gracelimit = SLAPD_DEFAULT_PW_GRACELIMIT;
pw_policy->pw_admin = NULL;
pw_policy->pw_admin_skip_info = LDAP_OFF;
pw_policy->pw_admin_user = NULL;
pw_policy->pw_is_legacy = LDAP_ON;
pw_policy->pw_track_update_time = LDAP_OFF;
Expand Down Expand Up @@ -1836,6 +1843,7 @@ FrontendConfig_init(void)
init_pwpolicy_inherit_global = cfg->pwpolicy_inherit_global = LDAP_OFF;
init_allow_hashed_pw = cfg->allow_hashed_pw = LDAP_OFF;
init_pw_is_global_policy = cfg->pw_is_global_policy = LDAP_OFF;
init_pw_admin_skip_info = cfg->pw_admin_skip_info = LDAP_OFF;

init_accesslog_logging_enabled = cfg->accesslog_logging_enabled = LDAP_ON;
cfg->accesslog_mode = slapi_ch_strdup(SLAPD_INIT_LOG_MODE);
Expand Down Expand Up @@ -4155,6 +4163,21 @@ config_set_pw_admin_dn(const char *attrname __attribute__((unused)), char *value
return retVal;
}

int32_t
config_set_pw_admin_skip_info(const char *attrname, char *value, char *errorbuf, int apply)
{
int32_t retVal = LDAP_SUCCESS;
slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();

retVal = config_set_onoff(attrname,
value,
&(slapdFrontendConfig->pw_policy.pw_admin_skip_info),
errorbuf,
apply);

return retVal;
}

int32_t
config_set_pw_track_last_update_time(const char *attrname, char *value, char *errorbuf, int apply)
{
Expand Down Expand Up @@ -4215,7 +4238,6 @@ config_set_pw_unlock(const char *attrname, char *value, char *errorbuf, int appl
return retVal;
}


int32_t
config_set_pw_lockout(const char *attrname, char *value, char *errorbuf, int apply)
{
Expand Down Expand Up @@ -6010,6 +6032,13 @@ config_get_pw_admin_dn(void)
return retVal;
}

int
config_get_pw_admin_skip_update(void)
{
slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig();
return (int)slapdFrontendConfig->pw_policy.pw_admin_skip_info;
}

char *
config_get_pw_storagescheme(void)
{
Expand Down
4 changes: 2 additions & 2 deletions ldap/servers/slapd/modify.c
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,7 @@ op_shared_allow_pw_change(Slapi_PBlock *pb, LDAPMod *mod, char **old_pw, Slapi_M
slapi_pblock_set(pb, SLAPI_BACKEND, slapi_be_select(&sdn));

/* Check if ACIs allow password to be changed */
if (!pw_is_pwp_admin(pb, pwpolicy) && (res = slapi_acl_check_mods(pb, e, mods, &errtxt)) != LDAP_SUCCESS) {
if (!pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN) && (res = slapi_acl_check_mods(pb, e, mods, &errtxt)) != LDAP_SUCCESS) {
if (operation_is_flag_set(operation, OP_FLAG_ACTION_LOG_ACCESS)) {
if (proxydn) {
proxystr = slapi_ch_smprintf(" authzid=\"%s\"", proxydn);
Expand All @@ -1234,7 +1234,7 @@ op_shared_allow_pw_change(Slapi_PBlock *pb, LDAPMod *mod, char **old_pw, Slapi_M
* If this mod is being performed by a password administrator/rootDN,
* just return success.
*/
if (pw_is_pwp_admin(pb, pwpolicy)) {
if (pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN)) {
if (!SLAPI_IS_MOD_DELETE(mod->mod_op) && pwpolicy->pw_history) {
/* Updating pw history, get the old password */
get_old_pw(pb, &sdn, old_pw);
Expand Down
1 change: 1 addition & 0 deletions ldap/servers/slapd/proto-slap.h
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ int config_set_pw_is_legacy_policy(const char *attrname, char *value, char *erro
int config_set_pw_track_last_update_time(const char *attrname, char *value, char *errorbuf, int apply);
int config_set_pw_gracelimit(const char *attrname, char *value, char *errorbuf, int apply);
int config_set_pw_admin_dn(const char *attrname, char *value, char *errorbuf, int apply);
int32_t config_set_pw_admin_skip_info(const char *attrname, char *value, char *errorbuf, int apply);
int config_set_pw_send_expiring(const char *attrname, char *value, char *errorbuf, int apply);
int config_set_useroc(const char *attrname, char *value, char *errorbuf, int apply);
int config_set_return_exact_case(const char *attrname, char *value, char *errorbuf, int apply);
Expand Down
16 changes: 12 additions & 4 deletions ldap/servers/slapd/pw.c
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,9 @@ update_pw_info(Slapi_PBlock *pb, char *old_pw)
internal_op = slapi_operation_is_flag_set(operation, SLAPI_OP_FLAG_INTERNAL);
target_dn = slapi_sdn_get_ndn(sdn);
pwpolicy = new_passwdPolicy(pb, target_dn);
if (pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_ONLY) && pwpolicy->pw_admin_skip_info) {
return 0;
}
cur_time = slapi_current_utc_time();
slapi_mods_init(&smods, 0);

Expand Down Expand Up @@ -744,7 +747,7 @@ update_pw_info(Slapi_PBlock *pb, char *old_pw)
*/
if ((internal_op && pwpolicy->pw_must_change && (!pb_conn || strcasecmp(target_dn, pb_conn->c_dn))) ||
(!internal_op && pwpolicy->pw_must_change &&
((target_dn && bind_dn && strcasecmp(target_dn, bind_dn)) && pw_is_pwp_admin(pb, pwpolicy))))
((target_dn && bind_dn && strcasecmp(target_dn, bind_dn)) && pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN))))
{
pw_exp_date = NO_TIME;
slapi_mods_add_string(&smods, LDAP_MOD_REPLACE, "pwdReset", "TRUE");
Expand Down Expand Up @@ -1116,7 +1119,7 @@ check_pw_syntax_ext(Slapi_PBlock *pb, const Slapi_DN *sdn, Slapi_Value **vals, c
if (slapi_is_encoded((char *)slapi_value_get_string(vals[i]))) {
if (!is_replication && !config_get_allow_hashed_pw() &&
((internal_op && pb_conn && !slapi_dn_isroot(pb_conn->c_dn)) ||
(!internal_op && !pw_is_pwp_admin(pb, pwpolicy))))
(!internal_op && !pw_is_pwp_admin(pb, pwpolicy, PWP_ADMIN_OR_ROOTDN))))
{
report_pw_violation(pb, dn, pwresponse_req, "invalid password syntax - passwords with storage scheme are not allowed");
return (1);
Expand Down Expand Up @@ -1894,7 +1897,7 @@ check_trivial_words(Slapi_PBlock *pb, Slapi_Entry *e, Slapi_Value **vals, char *
}

int
pw_is_pwp_admin(Slapi_PBlock *pb, passwdPolicy *pwp)
pw_is_pwp_admin(Slapi_PBlock *pb, passwdPolicy *pwp, int rootdn_flag)
{
Slapi_DN *bind_sdn = NULL;
int i;
Expand All @@ -1903,7 +1906,7 @@ pw_is_pwp_admin(Slapi_PBlock *pb, passwdPolicy *pwp)
slapi_pblock_get(pb, SLAPI_REQUESTOR_ISROOT, &is_requestor_root);

/* first check if it's root */
if (is_requestor_root) {
if (is_requestor_root && rootdn_flag == PWP_ADMIN_OR_ROOTDN) {
return 1;
}
/* now check if it's a Password Policy Administrator */
Expand Down Expand Up @@ -2323,6 +2326,11 @@ new_passwdPolicy(Slapi_PBlock *pb, const char *dn)
pwdpolicy->pw_admin = slapi_sdn_new_dn_byval(slapi_value_get_string(*sval));
pw_get_admin_users(pwdpolicy);
}
} else if (!strcasecmp(attr_name, "passwordAdminSkipInfoUpdate")) {
if ((sval = attr_get_present_values(attr))) {
pwdpolicy->pw_admin_skip_info =
pw_boolean_str2value(slapi_value_get_string(*sval));
}
} else if (!strcasecmp(attr_name, "passwordPalindrome")) {
if ((sval = attr_get_present_values(attr))) {
pwdpolicy->pw_palindrome =
Expand Down
5 changes: 4 additions & 1 deletion ldap/servers/slapd/pw.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ int check_pw_duration_value(const char *attr_name, char *value, long minval, lon
int check_pw_resetfailurecount_value(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen);
int check_pw_storagescheme_value(const char *attr_name, char *value, long minval, long maxval, char *errorbuf, size_t ebuflen);

int pw_is_pwp_admin(Slapi_PBlock *pb, struct passwordpolicyarray *pwp);
int pw_is_pwp_admin(Slapi_PBlock *pb, struct passwordpolicyarray *pwp, int rootdn_flag);
#define PWP_ADMIN_OR_ROOTDN 0
#define PWP_ADMIN_ONLY 1

/*
* Public functions from pw_retry.c:
*/
Expand Down
3 changes: 3 additions & 0 deletions ldap/servers/slapd/slap.h
Original file line number Diff line number Diff line change
Expand Up @@ -1878,6 +1878,7 @@ typedef struct passwordpolicyarray
struct pw_scheme *pw_storagescheme;
Slapi_DN *pw_admin;
Slapi_DN **pw_admin_user;
slapi_onoff_t pw_admin_skip_info; /* Skip updating password information in target entry */
char *pw_local_dn; /* DN of the subtree/user policy */

} passwdPolicy;
Expand Down Expand Up @@ -2287,6 +2288,7 @@ typedef struct _slapdEntryPoints
#define CONFIG_PW_IS_LEGACY "passwordLegacyPolicy"
#define CONFIG_PW_TRACK_LAST_UPDATE_TIME "passwordTrackUpdateTime"
#define CONFIG_PW_ADMIN_DN_ATTRIBUTE "passwordAdminDN"
#define CONFIG_PW_ADMIN_SKIP_INFO_ATTRIBUTE "passwordAdminSkipInfoUpdate"
#define CONFIG_PW_SEND_EXPIRING "passwordSendExpiringTime"
#define CONFIG_ACCESSLOG_BUFFERING_ATTRIBUTE "nsslapd-accesslog-logbuffering"
#define CONFIG_SECURITYLOG_BUFFERING_ATTRIBUTE "nsslapd-securitylog-logbuffering"
Expand Down Expand Up @@ -2710,6 +2712,7 @@ typedef struct _slapdFrontendConfig
slapi_int_t tcp_keepalive_time;
int32_t referral_check_period;
slapi_onoff_t return_orig_dn;
slapi_onoff_t pw_admin_skip_info;
char *auditlog_display_attrs;
} slapdFrontendConfig_t;

Expand Down
Loading

0 comments on commit 9908c98

Please sign in to comment.