new imports in admin
parent
9dc210d62d
commit
d918eb7b6f
|
@ -14,6 +14,8 @@ RUN apk del .build_deps
|
||||||
|
|
||||||
RUN apk add --no-cache curl py3-yaml yarn libpq openssl
|
RUN apk add --no-cache curl py3-yaml yarn libpq openssl
|
||||||
|
|
||||||
|
RUN wget -O /usr/lib/python3.8/site-packages/diceware/wordlists/wordlist_cat_ascii.txt https://raw.githubusercontent.com/1ma/diceware-cat/master/cat-wordlist-ascii.txt
|
||||||
|
|
||||||
# SSH configuration
|
# SSH configuration
|
||||||
ARG SSH_ROOT_PWD
|
ARG SSH_ROOT_PWD
|
||||||
RUN apk add openssh
|
RUN apk add openssh
|
||||||
|
|
|
@ -17,7 +17,7 @@ zope.interface==5.1.0
|
||||||
psycopg2==2.8.6
|
psycopg2==2.8.6
|
||||||
Flask-SocketIO==2.8.6
|
Flask-SocketIO==2.8.6
|
||||||
mysql-connector-python==8.0.25
|
mysql-connector-python==8.0.25
|
||||||
|
diceware==0.9.6
|
||||||
|
|
||||||
#Flask-SocketIO==2.8.6
|
#Flask-SocketIO==2.8.6
|
||||||
#gevent==1.4.0
|
#gevent==1.4.0
|
||||||
|
|
|
@ -13,9 +13,18 @@ from pprint import pprint
|
||||||
import traceback, os, json
|
import traceback, os, json
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
|
import diceware
|
||||||
|
options = diceware.handle_options(None)
|
||||||
|
options.wordlist = 'cat_ascii'
|
||||||
|
options.num = 3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
||||||
close_room, rooms, disconnect, send
|
close_room, rooms, disconnect, send
|
||||||
socketio = SocketIO(app)
|
socketio = SocketIO(app)
|
||||||
|
from ..views.Socketio import socketio
|
||||||
|
# socketio = SocketIO(app)
|
||||||
|
|
||||||
class Admin():
|
class Admin():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
@ -407,6 +416,7 @@ class Admin():
|
||||||
|
|
||||||
def upload_json(self,data):
|
def upload_json(self,data):
|
||||||
groups=[]
|
groups=[]
|
||||||
|
log.warning('Processing uploaded groups...')
|
||||||
for g in data['data']['groups']:
|
for g in data['data']['groups']:
|
||||||
# for m in data['data']['d_members']:
|
# for m in data['data']['d_members']:
|
||||||
|
|
||||||
|
@ -418,8 +428,12 @@ class Admin():
|
||||||
"description": g['description']})
|
"description": g['description']})
|
||||||
self.external['groups']=groups
|
self.external['groups']=groups
|
||||||
|
|
||||||
|
log.warning('Processing uploaded users...')
|
||||||
users=[]
|
users=[]
|
||||||
|
total=len(data['data']['users'])
|
||||||
|
i=1
|
||||||
for u in data['data']['users']:
|
for u in data['data']['users']:
|
||||||
|
log.warning('Processing ('+str(i)+'/'+str(total)+') uploaded user: '+u['primaryEmail'].split('@')[0])
|
||||||
# data['provider']
|
# data['provider']
|
||||||
users.append({'provider':'external',
|
users.append({'provider':'external',
|
||||||
'id':u['id'],
|
'id':u['id'],
|
||||||
|
@ -427,8 +441,14 @@ class Admin():
|
||||||
'first': u['name']['givenName'],
|
'first': u['name']['givenName'],
|
||||||
'last': u['name']['familyName'],
|
'last': u['name']['familyName'],
|
||||||
'username': u['primaryEmail'].split('@')[0],
|
'username': u['primaryEmail'].split('@')[0],
|
||||||
'groups':[u['orgUnitPath'][1:]], ## WARNING: Removing the first
|
'groups':[u['orgUnitPath']], ## WARNING: Removing the first
|
||||||
'roles':[]})
|
'roles':[],
|
||||||
|
'password':diceware.get_passphrase(options=options)})
|
||||||
|
socketio.emit('update',
|
||||||
|
json.dumps({'status':True,'item':'user','action':'delete','itemdata':''}),
|
||||||
|
namespace='/isard-sso-admin/sio',
|
||||||
|
room='admin')
|
||||||
|
i=i+1
|
||||||
self.external['users']=users
|
self.external['users']=users
|
||||||
|
|
||||||
## Add groups to users (now they only have their orgUnitPath)
|
## Add groups to users (now they only have their orgUnitPath)
|
||||||
|
@ -443,23 +463,84 @@ class Admin():
|
||||||
pprint(ids)
|
pprint(ids)
|
||||||
log.warning('Starting sync to keycloak')
|
log.warning('Starting sync to keycloak')
|
||||||
self.sync_to_keycloak()
|
self.sync_to_keycloak()
|
||||||
log.warning('Starting sync to moodle')
|
# log.warning('Starting sync to moodle')
|
||||||
self.sync_to_moodle()
|
# self.sync_to_moodle()
|
||||||
log.warning('Starting sync to nextcloud')
|
# log.warning('Starting sync to nextcloud')
|
||||||
self.sync_to_nextcloud()
|
# self.sync_to_nextcloud()
|
||||||
log.warning('All syncs finished')
|
# log.warning('All syncs finished')
|
||||||
|
|
||||||
def sync_to_keycloak(self):
|
def sync_to_keycloak(self): ### This one works from the external, moodle and nextcloud from the internal
|
||||||
|
groups=[]
|
||||||
for u in self.external['users']:
|
for u in self.external['users']:
|
||||||
log.info('Creating user: '+u['username'])
|
groups=groups+u['groups']
|
||||||
self.keycloak.add_user_with_groups_and_role(u['username'],u['first'],u['last'],u['email'],'1Provaprovaprova',u['roles'],u['groups'])
|
groups=list(dict.fromkeys(groups))
|
||||||
# self.keycloak.add_user(u['username'],u['first'],u['last'],u['email'],'1Provaprovaprova',group=u['groups'][0])
|
|
||||||
|
total=len(groups)
|
||||||
|
i=0
|
||||||
|
for g in groups:
|
||||||
|
i=i+1
|
||||||
|
# print('ADDING FULL PATH: '+str(g))
|
||||||
|
log.warning(' KEYCLOAK GROUPS: Adding group ('+str(i)+'/'+str(total)+'): '+g)
|
||||||
|
self.keycloak.add_group_tree(g)
|
||||||
|
|
||||||
|
total=len(self.external['users'])
|
||||||
|
index=0
|
||||||
|
for u in self.external['users']:
|
||||||
|
index=index+1
|
||||||
|
# Add user
|
||||||
|
log.warning(' KEYCLOAK USERS: Adding user ('+str(index)+'/'+str(total)+'): '+u['username'])
|
||||||
|
uid=self.keycloak.add_user(u['username'],u['first'],u['last'],u['email'],u['password'])
|
||||||
|
|
||||||
|
# Add user to role and group rolename
|
||||||
|
if len(u['roles']) != 0:
|
||||||
|
log.warning(' KEYCLOAK USERS: Assign user '+u['username']+' with initial pwd '+ u['password']+' to role '+u['roles'][0])
|
||||||
|
self.keycloak.assign_realm_roles(uid,u['roles'][0])
|
||||||
|
gid=self.keycloak.get_group(path='/'+u['roles'][0])['id']
|
||||||
|
self.keycloak.group_user_add(uid,gid)
|
||||||
|
# Add user to groups
|
||||||
|
for g in u['groups']:
|
||||||
|
parts=g.split('/')
|
||||||
|
sub=''
|
||||||
|
if len(parts)==0:
|
||||||
|
log.warning(' KEYCLOAK USERS: Skip assign user '+u['username']+' to any group as des not have one')
|
||||||
|
continue # NO GROUP
|
||||||
|
for i in range(1,len(parts)):
|
||||||
|
sub=sub+'/'+parts[i]
|
||||||
|
|
||||||
|
if sub=='/': continue # User with no path
|
||||||
|
log.warning(' KEYCLOAK USERS: Assign user '+u['username']+' to group '+ str(sub))
|
||||||
|
gid=self.keycloak.get_group(path=sub)['id']
|
||||||
|
self.keycloak.group_user_add(uid,gid)
|
||||||
|
self.resync_data()
|
||||||
|
|
||||||
|
def sync_to_moodle(self): # works from the internal (keycloak)
|
||||||
|
groups=[]
|
||||||
|
for u in self.internal['users']:
|
||||||
|
groups=groups+u['keycloak_groups']
|
||||||
|
groups=list(dict.fromkeys(groups))
|
||||||
|
|
||||||
|
pprint(groups)
|
||||||
|
total=len(groups)
|
||||||
|
i=0
|
||||||
|
for g in groups:
|
||||||
|
parts=g.split('/')
|
||||||
|
subpath=''
|
||||||
|
for i in range(1,len(parts)):
|
||||||
|
try:
|
||||||
|
log.warning(' MOODLE GROUPS: Adding group as cohort ('+str(i)+'/'+str(total)+'): '+subpath)
|
||||||
|
subpath=subpath+'/'+parts[i]
|
||||||
|
self.moodle.add_system_cohort(subpath)
|
||||||
|
except:
|
||||||
|
log.error('probably exists')
|
||||||
|
i=i+1
|
||||||
|
# print('ADDING FULL PATH: '+str(g))
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def sync_to_moodle(self):
|
|
||||||
for u in self.internal['users']:
|
for u in self.internal['users']:
|
||||||
if not u['moodle']:
|
if not u['moodle']:
|
||||||
log.info('Creating moodle user: '+u['username'])
|
log.info('Creating moodle user: '+u['username'])
|
||||||
self.moodle.create_user(u['email'],u['username'],'-1Provaprovaprova',u['first'],u['last'])
|
self.moodle.create_user(u['email'],u['username'],u['password'],u['first'],u['last'])
|
||||||
|
|
||||||
def sync_to_nextcloud(self):
|
def sync_to_nextcloud(self):
|
||||||
for u in self.internal['users']:
|
for u in self.internal['users']:
|
||||||
|
@ -467,7 +548,7 @@ class Admin():
|
||||||
log.info('Creating nextcloud user: '+u['username'])
|
log.info('Creating nextcloud user: '+u['username'])
|
||||||
group=u['keycloak_groups'][0] if len(u['keycloak_groups']) else False
|
group=u['keycloak_groups'][0] if len(u['keycloak_groups']) else False
|
||||||
try:
|
try:
|
||||||
self.nextcloud.add_user(u['username'],'-1Provaprovaprova',1000,group,u['email'],u['first']+' '+u['last'])
|
self.nextcloud.add_user(u['username'],u['password'],2000000000000,group,u['email'],u['first']+' '+u['last'])
|
||||||
except ProviderItemExists:
|
except ProviderItemExists:
|
||||||
log.info('User '+u['username']+' already exists. Skipping...')
|
log.info('User '+u['username']+' already exists. Skipping...')
|
||||||
continue
|
continue
|
||||||
|
@ -476,12 +557,15 @@ class Admin():
|
||||||
# <div>Las contraseñas deben tener al menos 1 dígito(s).</div><div>Las contraseñas deben tener al menos 1 mayúscula(s).</div><div>Las contraseñas deben tener al menos 1 caracter(es) no alfanumérico(s) como *,-,
|
# <div>Las contraseñas deben tener al menos 1 dígito(s).</div><div>Las contraseñas deben tener al menos 1 mayúscula(s).</div><div>Las contraseñas deben tener al menos 1 caracter(es) no alfanumérico(s) como *,-,
|
||||||
|
|
||||||
def delete_keycloak_users(self):
|
def delete_keycloak_users(self):
|
||||||
|
total=len(self.internal['users'])
|
||||||
|
i=0
|
||||||
for u in self.internal['users']:
|
for u in self.internal['users']:
|
||||||
|
i=i+1
|
||||||
if not u['keycloak']: continue
|
if not u['keycloak']: continue
|
||||||
# Do not remove admin users!!! What to do with managers???
|
# Do not remove admin users!!! What to do with managers???
|
||||||
if 'admin' in u['roles']: continue
|
if 'admin' in u['roles']: continue
|
||||||
if 'manager' in u['roles']: continue
|
# if 'manager' in u['roles']: continue
|
||||||
log.info('Removing keycloak user: '+u['username'])
|
log.info(' KEYCLOAK USERS: Removing user ('+str(i)+'/'+str(total)+'): '+u['username'])
|
||||||
try:
|
try:
|
||||||
self.keycloak.delete_user(u['id'])
|
self.keycloak.delete_user(u['id'])
|
||||||
socketio.emit('update',
|
socketio.emit('update',
|
||||||
|
@ -489,8 +573,7 @@ class Admin():
|
||||||
namespace='/isard-sso-admin/sio',
|
namespace='/isard-sso-admin/sio',
|
||||||
room='admin')
|
room='admin')
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.warning(' KEYCLOAK USERS: Could not remove user: '+u['username'] +'. Probably already not exists.')
|
||||||
log.warning('Could not remove user: '+u['username'])
|
|
||||||
|
|
||||||
def delete_nextcloud_users(self):
|
def delete_nextcloud_users(self):
|
||||||
for u in self.internal['users']:
|
for u in self.internal['users']:
|
||||||
|
|
|
@ -84,6 +84,35 @@ class KeycloakClient():
|
||||||
left join keycloak_role as r on r.id = rm.role_id
|
left join keycloak_role as r on r.id = rm.role_id
|
||||||
group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
order by u.username"""
|
order by u.username"""
|
||||||
|
|
||||||
|
# q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, ua.value as quota, g.id, g.path, g.name,
|
||||||
|
# --,json_agg(g."name") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
|
# --,json_agg(r.name) as role
|
||||||
|
# from user_entity as u
|
||||||
|
# left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
|
||||||
|
# left join user_group_membership as ugm on ugm.user_id = u.id
|
||||||
|
# left join keycloak_group as g on g.id = ugm.group_id
|
||||||
|
# --left join keycloak_group as g_parent on g.parent_group = g_parent.id
|
||||||
|
# --left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
|
||||||
|
# left join user_role_mapping as rm on rm.user_id = u.id
|
||||||
|
# left join keycloak_role as r on r.id = rm.role_id
|
||||||
|
# --group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
|
# order by u.username"""
|
||||||
|
|
||||||
|
# q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, ua.value as quota
|
||||||
|
# ,json_agg(g."name") as group_name,json_agg(g."id") as group_id,json_agg(g."path") as group_path
|
||||||
|
# ,json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
|
# ,json_agg(r.name) as role
|
||||||
|
# from user_entity as u
|
||||||
|
# left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
|
||||||
|
# left join user_group_membership as ugm on ugm.user_id = u.id
|
||||||
|
# left join keycloak_group as g on g.id = ugm.group_id
|
||||||
|
# left join keycloak_group as g_parent on g.parent_group = g_parent.id
|
||||||
|
# left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
|
||||||
|
# left join user_role_mapping as rm on rm.user_id = u.id
|
||||||
|
# left join keycloak_role as r on r.id = rm.role_id
|
||||||
|
# group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
|
# order by u.username"""
|
||||||
(headers,users)=self.keycloak_pg.select_with_headers(q)
|
(headers,users)=self.keycloak_pg.select_with_headers(q)
|
||||||
|
|
||||||
users_with_lists = [list(l[:-4])+([[]] if l[-4] == [None] else [list(set(l[-4]))]) +\
|
users_with_lists = [list(l[:-4])+([[]] if l[-4] == [None] else [list(set(l[-4]))]) +\
|
||||||
|
@ -108,7 +137,7 @@ class KeycloakClient():
|
||||||
# user['roles']= [r['name'] for r in self.keycloak_admin.get_realm_roles_of_user(user_id=user['id'])]
|
# user['roles']= [r['name'] for r in self.keycloak_admin.get_realm_roles_of_user(user_id=user['id'])]
|
||||||
# return users
|
# return users
|
||||||
|
|
||||||
def add_user(self,username,first,last,email,password,group=False):
|
def add_user(self,username,first,last,email,password,group=False,temporary=True):
|
||||||
# Returns user id
|
# Returns user id
|
||||||
self.connect()
|
self.connect()
|
||||||
username=username.lower()
|
username=username.lower()
|
||||||
|
@ -120,7 +149,7 @@ class KeycloakClient():
|
||||||
"lastName": last,
|
"lastName": last,
|
||||||
"credentials":[{"type":"password",
|
"credentials":[{"type":"password",
|
||||||
"value":password,
|
"value":password,
|
||||||
"temporary":False}]})
|
"temporary":temporary}]})
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
|
@ -134,6 +163,13 @@ class KeycloakClient():
|
||||||
self.keycloak_admin.group_user_add(uid,gid)
|
self.keycloak_admin.group_user_add(uid,gid)
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
def update_user_pwd(self,user_id='61092e24-cd67-4b50-baf9-b60e01f12bff',payload={},temporary=True):
|
||||||
|
payload={"credentials":[{"type":"password",
|
||||||
|
"value":'pepito',
|
||||||
|
"temporary":temporary}]}
|
||||||
|
self.connect()
|
||||||
|
self.keycloak_admin.update_user( user_id, payload)
|
||||||
|
|
||||||
def remove_user_group(self,user_id,group_id):
|
def remove_user_group(self,user_id,group_id):
|
||||||
self.connect()
|
self.connect()
|
||||||
pass
|
pass
|
||||||
|
@ -196,15 +232,31 @@ class KeycloakClient():
|
||||||
|
|
||||||
def add_group_tree(self,path):
|
def add_group_tree(self,path):
|
||||||
parts=path.split('/')
|
parts=path.split('/')
|
||||||
sub=''
|
parent_path=None
|
||||||
for i in range(1,len(parts+1)):
|
for i in range(1,len(parts)):
|
||||||
|
# print('Adding group name '+parts[i]+' with parent path '+str(parent_path))
|
||||||
try:
|
try:
|
||||||
if i == 1: parent_id=self.add_group(parts[i])
|
self.add_group(parts[i],parent_path,skip_exists=True)
|
||||||
except:
|
except:
|
||||||
# Main already exists?? What a fail!
|
if parent_path==None:
|
||||||
parent_id=self.get_group(parent_id)['id']
|
parent_path='/'+parts[i]
|
||||||
|
else:
|
||||||
|
parent_path=self.get_group(parent_path)['path']
|
||||||
|
parent_path=parent_path+parts[i]
|
||||||
continue
|
continue
|
||||||
self.add_group(parts[i],parent_id)
|
|
||||||
|
if parent_path==None:
|
||||||
|
parent_path='/'+parts[i]
|
||||||
|
else:
|
||||||
|
parent_path=parent_path+parts[i]
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# if i == 1: parent_id=self.add_group(parts[i])
|
||||||
|
# except:
|
||||||
|
# # Main already exists?? What a fail!
|
||||||
|
# parent_id=self.get_group(parent_id)['id']
|
||||||
|
# continue
|
||||||
|
# self.add_group(parts[i],parent_id)
|
||||||
|
|
||||||
def add_user_with_groups_and_role(self,username,first,last,email,password,role,groups):
|
def add_user_with_groups_and_role(self,username,first,last,email,password,role,groups):
|
||||||
## Add user
|
## Add user
|
||||||
|
@ -232,9 +284,12 @@ class KeycloakClient():
|
||||||
thepath='/'+parts[i]
|
thepath='/'+parts[i]
|
||||||
else:
|
else:
|
||||||
thepath=parent_path+'/'+parts[i]
|
thepath=parent_path+'/'+parts[i]
|
||||||
if thepath=='/': continue
|
if thepath=='/':
|
||||||
|
print('Not adding the user '+username+' to any group as does not have any...')
|
||||||
|
continue
|
||||||
gid=self.get_group(path=thepath)['id']
|
gid=self.get_group(path=thepath)['id']
|
||||||
|
|
||||||
|
print('Adding '+username+' with uuid: '+uid+' to group '+g+' with uuid: '+gid)
|
||||||
self.keycloak_admin.group_user_add(uid,gid)
|
self.keycloak_admin.group_user_add(uid,gid)
|
||||||
|
|
||||||
|
|
||||||
|
@ -242,7 +297,7 @@ class KeycloakClient():
|
||||||
parent_path=parent_path+'/'+parts[i]
|
parent_path=parent_path+'/'+parts[i]
|
||||||
|
|
||||||
|
|
||||||
self.group_user_add(uid,gid)
|
# self.group_user_add(uid,gid)
|
||||||
|
|
||||||
|
|
||||||
## ROLES
|
## ROLES
|
||||||
|
|
|
@ -130,6 +130,18 @@ class Moodle():
|
||||||
cohort = self.call('core_cohort_create_cohorts', cohorts=data)
|
cohort = self.call('core_cohort_create_cohorts', cohorts=data)
|
||||||
return cohort
|
return cohort
|
||||||
|
|
||||||
|
# def add_users_to_cohort(self,users,cohort):
|
||||||
|
# criteria = [{'key': key, 'value': value}]
|
||||||
|
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
|
||||||
|
# return user
|
||||||
|
|
||||||
|
# def add_users_to_cohort(self,userid,cohortid):
|
||||||
|
# members=[{'cohorttype':{'type':'system','value':cohortid},
|
||||||
|
# 'usertype':{'type':'id','value':userid}}]
|
||||||
|
# criteria = [{'key': key, 'value': value}]
|
||||||
|
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
|
||||||
|
# return user
|
||||||
|
|
||||||
def get_cohort_members(self, cohort_id):
|
def get_cohort_members(self, cohort_id):
|
||||||
members = self.call('core_cohort_get_cohort_members', cohortids=[cohort_id])[0]['userids']
|
members = self.call('core_cohort_get_cohort_members', cohortids=[cohort_id])[0]['userids']
|
||||||
return members
|
return members
|
||||||
|
|
|
@ -15,7 +15,7 @@ $(document).ready(function() {
|
||||||
$('.btn-sync').on('click', function () {
|
$('.btn-sync').on('click', function () {
|
||||||
ids={}
|
ids={}
|
||||||
$.each(users_table.rows().data(),function(key, value){
|
$.each(users_table.rows().data(),function(key, value){
|
||||||
ids[value['id']]=value['role']
|
ids[value['id']]=value['roles']
|
||||||
});
|
});
|
||||||
console.log(ids)
|
console.log(ids)
|
||||||
$.ajax({
|
$.ajax({
|
||||||
|
@ -36,6 +36,15 @@ $(document).ready(function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('.btn-download').on('click', function () {
|
||||||
|
|
||||||
|
data=users_table.rows().data()
|
||||||
|
csv_data='TYPE,EXTID,EMAIL,FIRST,LSAT,USERNAME,GROUPS,ROLE,PASSWORD'+ '\r\n'
|
||||||
|
csv_data=csv_data+convertToCSV(data)
|
||||||
|
console.log(csv_data)
|
||||||
|
exportCSVFile(csv_data, 'users_data')
|
||||||
|
})
|
||||||
|
|
||||||
$("#modalImport #send").on('click', function(e){
|
$("#modalImport #send").on('click', function(e){
|
||||||
console.log(users_table.rows().data())
|
console.log(users_table.rows().data())
|
||||||
var form = $('#modalImportForm');
|
var form = $('#modalImportForm');
|
||||||
|
@ -143,6 +152,7 @@ $(document).ready(function() {
|
||||||
{ "data": "email", "width": "10px"},
|
{ "data": "email", "width": "10px"},
|
||||||
{ "data": "groups", "width": "10px"},
|
{ "data": "groups", "width": "10px"},
|
||||||
{ "data": "roles", "width": "10px"},
|
{ "data": "roles", "width": "10px"},
|
||||||
|
{ "data": "password", "width": "10px"},
|
||||||
],
|
],
|
||||||
"order": [[3, 'asc']],
|
"order": [[3, 'asc']],
|
||||||
"columnDefs": [ {
|
"columnDefs": [ {
|
||||||
|
@ -228,3 +238,46 @@ function populate_path(){
|
||||||
$(".populate").append('<option value=' + value['path']+ '>' + value['path'] + '</option>');
|
$(".populate").append('<option value=' + value['path']+ '>' + value['path'] + '</option>');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertToCSV(objArray) {
|
||||||
|
var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray;
|
||||||
|
var str = '';
|
||||||
|
|
||||||
|
for (var i = 0; i < array.length; i++) {
|
||||||
|
var line = '';
|
||||||
|
for (var index in array[i]) {
|
||||||
|
if (line != '') line += ','
|
||||||
|
|
||||||
|
if (Array.isArray(array[i][index])){
|
||||||
|
line += '"'+array[i][index]+'"'
|
||||||
|
}else{
|
||||||
|
line += array[i][index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
str += line + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSVFile(csv, fileTitle) {
|
||||||
|
var exportedFilenmae = fileTitle + '.csv' || 'export.csv';
|
||||||
|
|
||||||
|
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
if (navigator.msSaveBlob) { // IE 10+
|
||||||
|
navigator.msSaveBlob(blob, exportedFilenmae);
|
||||||
|
} else {
|
||||||
|
var link = document.createElement("a");
|
||||||
|
if (link.download !== undefined) { // feature detection
|
||||||
|
// Browsers that support HTML5 download attribute
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", exportedFilenmae);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -111,6 +111,7 @@
|
||||||
<script src="/isard-sso-admin/vendors/select2/dist/js/select2.full.min.js"></script>
|
<script src="/isard-sso-admin/vendors/select2/dist/js/select2.full.min.js"></script>
|
||||||
<!-- SocketIO -->
|
<!-- SocketIO -->
|
||||||
<script src="/isard-sso-admin/static/vendor/socket.io-2.3.1.slim.js"></script>
|
<script src="/isard-sso-admin/static/vendor/socket.io-2.3.1.slim.js"></script>
|
||||||
|
<script src="/isard-sso-admin/static/js/status_socket.js"></script>
|
||||||
|
|
||||||
<!-- isard initializers -->
|
<!-- isard initializers -->
|
||||||
<script src="/isard-sso-admin/static/dd.js"></script>
|
<script src="/isard-sso-admin/static/dd.js"></script>
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
<th>email</th>
|
<th>email</th>
|
||||||
<th>groups</th>
|
<th>groups</th>
|
||||||
<th>roles</th>
|
<th>roles</th>
|
||||||
|
<th>password</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
@ -75,5 +75,4 @@
|
||||||
<script src="/isard-sso-admin/vendors/switchery/dist/switchery.min.js"></script>
|
<script src="/isard-sso-admin/vendors/switchery/dist/switchery.min.js"></script>
|
||||||
<!-- Desktops sse & modals -->
|
<!-- Desktops sse & modals -->
|
||||||
<script src="/isard-sso-admin/static/js/users.js"></script>
|
<script src="/isard-sso-admin/static/js/users.js"></script>
|
||||||
<script src="/isard-sso-admin/static/js/status_socket.js"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
||||||
close_room, rooms, disconnect, send
|
close_room, rooms, disconnect, send
|
||||||
from admin import app
|
from admin import app
|
||||||
|
import json
|
||||||
|
|
||||||
socketio = SocketIO(app)
|
socketio = SocketIO(app)
|
||||||
|
# from ...start import socketio
|
||||||
|
|
||||||
@socketio.on('connect', namespace='/isard-sso-admin/sio')
|
@socketio.on('connect', namespace='/isard-sso-admin/sio')
|
||||||
def socketio_connect():
|
def socketio_connect():
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import diceware
|
||||||
|
options = diceware.handle_options(None)
|
||||||
|
options.wordlist = 'cat_ascii'
|
||||||
|
options.num = 3
|
||||||
|
print(diceware.get_passphrase(options=options))
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# coding=utf-8
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import pprint
|
||||||
|
|
||||||
|
import logging as log
|
||||||
|
import traceback
|
||||||
|
import yaml, json
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
class Postgres():
|
||||||
|
|
||||||
|
def __init__(self,host,database,user,password):
|
||||||
|
self.conn = psycopg2.connect(
|
||||||
|
host=host,
|
||||||
|
database=database,
|
||||||
|
user=user,
|
||||||
|
password=password)
|
||||||
|
|
||||||
|
|
||||||
|
# def __del__(self):
|
||||||
|
# self.cur.close()
|
||||||
|
# self.conn.close()
|
||||||
|
|
||||||
|
def select(self,sql):
|
||||||
|
self.cur = self.conn.cursor()
|
||||||
|
self.cur.execute(sql)
|
||||||
|
data=self.cur.fetchall()
|
||||||
|
self.cur.close()
|
||||||
|
return data
|
||||||
|
|
||||||
|
def update(self,sql):
|
||||||
|
self.cur = self.conn.cursor()
|
||||||
|
self.cur.execute(sql)
|
||||||
|
self.conn.commit()
|
||||||
|
self.cur.close()
|
||||||
|
# return self.cur.fetchall()
|
||||||
|
|
||||||
|
def select_with_headers(self,sql):
|
||||||
|
self.cur = self.conn.cursor()
|
||||||
|
self.cur.execute(sql)
|
||||||
|
data=self.cur.fetchall()
|
||||||
|
fields = [a.name for a in self.cur.description]
|
||||||
|
self.cur.close()
|
||||||
|
return (fields,data)
|
||||||
|
|
||||||
|
# def update_moodle_saml_plugin(self):
|
||||||
|
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.santantoni.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
|
||||||
|
# pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
|
||||||
|
# cursor.execute(pg_update, (title, bookid))
|
||||||
|
# connection.commit()
|
||||||
|
# count = cursor.rowcount
|
||||||
|
# print(count, "Successfully Updated!")
|
|
@ -0,0 +1,134 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import time ,os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import logging as log
|
||||||
|
import traceback
|
||||||
|
import yaml, json
|
||||||
|
from pprint import pprint
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from keycloak import KeycloakAdmin
|
||||||
|
from postgres import Postgres
|
||||||
|
|
||||||
|
import diceware
|
||||||
|
options = diceware.handle_options(None)
|
||||||
|
options.wordlist = 'cat_ascii'
|
||||||
|
options.num = 3
|
||||||
|
|
||||||
|
class KeycloakClient():
|
||||||
|
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
|
||||||
|
https://github.com/marcospereirampj/python-keycloak
|
||||||
|
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
|
||||||
|
"""
|
||||||
|
def __init__(self,
|
||||||
|
url="http://isard-sso-keycloak:8080/auth/",
|
||||||
|
username=os.environ['KEYCLOAK_USER'],
|
||||||
|
password=os.environ['KEYCLOAK_PASSWORD'],
|
||||||
|
realm='master',
|
||||||
|
verify=True):
|
||||||
|
self.url=url
|
||||||
|
self.username=username
|
||||||
|
self.password=password
|
||||||
|
self.realm=realm
|
||||||
|
self.verify=verify
|
||||||
|
|
||||||
|
self.keycloak_pg=Postgres('isard-apps-postgresql','keycloak',os.environ['KEYCLOAK_DB_USER'],os.environ['KEYCLOAK_DB_PASSWORD'])
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.keycloak_admin = KeycloakAdmin(server_url=self.url,
|
||||||
|
username=self.username,
|
||||||
|
password=self.password,
|
||||||
|
realm_name=self.realm,
|
||||||
|
verify=self.verify)
|
||||||
|
|
||||||
|
|
||||||
|
def update_pwds(self):
|
||||||
|
self.get_users()
|
||||||
|
|
||||||
|
def get_users(self):
|
||||||
|
self.connect()
|
||||||
|
users=self.get_users_with_groups_and_roles()
|
||||||
|
userupdate=[]
|
||||||
|
for u in users:
|
||||||
|
if u['username'] not in ['admin'] and not u['username'].startswith('system_'):
|
||||||
|
print('Generating password for user '+u['username'])
|
||||||
|
userupdate.append({'id':u['id'],
|
||||||
|
'username':u['username'],
|
||||||
|
'password': 'pepito'})
|
||||||
|
# diceware.get_passphrase(options=options)})
|
||||||
|
with open("user_temp_passwd.csv"),"w") as csv:
|
||||||
|
csv.write(self.parse_idp_metadata())
|
||||||
|
for u in userupdate:
|
||||||
|
print('Updating keycloak password for user '+u['username'])
|
||||||
|
self.update_user_pwd(u['id'],u['password'])
|
||||||
|
|
||||||
|
def update_user_pwd(self,user_id,password,temporary=True):
|
||||||
|
payload={"credentials":[{"type":"password",
|
||||||
|
"value":password,
|
||||||
|
"temporary":temporary}]}
|
||||||
|
self.connect()
|
||||||
|
self.keycloak_admin.update_user( user_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_with_groups_and_roles(self):
|
||||||
|
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, ua.value as quota
|
||||||
|
,json_agg(g."name") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
|
,json_agg(r.name) as role
|
||||||
|
from user_entity as u
|
||||||
|
left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
|
||||||
|
left join user_group_membership as ugm on ugm.user_id = u.id
|
||||||
|
left join keycloak_group as g on g.id = ugm.group_id
|
||||||
|
left join keycloak_group as g_parent on g.parent_group = g_parent.id
|
||||||
|
left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
|
||||||
|
left join user_role_mapping as rm on rm.user_id = u.id
|
||||||
|
left join keycloak_role as r on r.id = rm.role_id
|
||||||
|
group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
|
order by u.username"""
|
||||||
|
|
||||||
|
# q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, ua.value as quota, g.id, g.path, g.name,
|
||||||
|
# --,json_agg(g."name") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
|
# --,json_agg(r.name) as role
|
||||||
|
# from user_entity as u
|
||||||
|
# left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
|
||||||
|
# left join user_group_membership as ugm on ugm.user_id = u.id
|
||||||
|
# left join keycloak_group as g on g.id = ugm.group_id
|
||||||
|
# --left join keycloak_group as g_parent on g.parent_group = g_parent.id
|
||||||
|
# --left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
|
||||||
|
# left join user_role_mapping as rm on rm.user_id = u.id
|
||||||
|
# left join keycloak_role as r on r.id = rm.role_id
|
||||||
|
# --group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
|
# order by u.username"""
|
||||||
|
|
||||||
|
# q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, ua.value as quota
|
||||||
|
# ,json_agg(g."name") as group_name,json_agg(g."id") as group_id,json_agg(g."path") as group_path
|
||||||
|
# ,json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
|
# ,json_agg(r.name) as role
|
||||||
|
# from user_entity as u
|
||||||
|
# left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
|
||||||
|
# left join user_group_membership as ugm on ugm.user_id = u.id
|
||||||
|
# left join keycloak_group as g on g.id = ugm.group_id
|
||||||
|
# left join keycloak_group as g_parent on g.parent_group = g_parent.id
|
||||||
|
# left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
|
||||||
|
# left join user_role_mapping as rm on rm.user_id = u.id
|
||||||
|
# left join keycloak_role as r on r.id = rm.role_id
|
||||||
|
# group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, ua.value
|
||||||
|
# order by u.username"""
|
||||||
|
(headers,users)=self.keycloak_pg.select_with_headers(q)
|
||||||
|
|
||||||
|
users_with_lists = [list(l[:-4])+([[]] if l[-4] == [None] else [list(set(l[-4]))]) +\
|
||||||
|
([[]] if l[-3] == [None] else [list(set(l[-3]))]) +\
|
||||||
|
([[]] if l[-3] == [None] else [list(set(l[-2]))]) +\
|
||||||
|
([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
|
||||||
|
|
||||||
|
users_with_lists = [list(l[:-4])+([[]] if l[-4] == [None] else [list(set(l[-4]))]) +\
|
||||||
|
([[]] if l[-3] == [None] else [list(set(l[-3]))]) +\
|
||||||
|
([[]] if l[-3] == [None] else [list(set(l[-2]))]) +\
|
||||||
|
([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
|
||||||
|
|
||||||
|
list_dict_users = [dict(zip(headers, r)) for r in users_with_lists]
|
||||||
|
return list_dict_users
|
||||||
|
|
||||||
|
k=KeycloakClient()
|
||||||
|
k.update_pwds()
|
|
@ -8,21 +8,22 @@ from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
||||||
import json
|
import json
|
||||||
from admin import app
|
from admin import app
|
||||||
|
|
||||||
socketio = SocketIO(app)
|
|
||||||
socketio.init_app(app, cors_allowed_origins="*")
|
|
||||||
|
|
||||||
# from .admin.views.Socketio import *
|
# socketio.init_app(app, cors_allowed_origins="*")
|
||||||
@socketio.on('connect', namespace='/isard-sso-admin/sio')
|
|
||||||
def socketio_connect():
|
|
||||||
join_room('admin')
|
|
||||||
socketio.emit('update',
|
|
||||||
json.dumps('Joined'),
|
|
||||||
namespace='/isard-sso-admin/sio',
|
|
||||||
room='admin')
|
|
||||||
|
|
||||||
@socketio.on('disconnect', namespace='/isard-sso-admin/sio')
|
from admin.views.Socketio import *
|
||||||
def socketio_domains_disconnect():
|
# socketio = SocketIO(app)
|
||||||
None
|
# @socketio.on('connect', namespace='/isard-sso-admin/sio')
|
||||||
|
# def socketio_connect():
|
||||||
|
# join_room('admin')
|
||||||
|
# socketio.emit('update',
|
||||||
|
# json.dumps('Joined'),
|
||||||
|
# namespace='/isard-sso-admin/sio',
|
||||||
|
# room='admin')
|
||||||
|
|
||||||
|
# @socketio.on('disconnect', namespace='/isard-sso-admin/sio')
|
||||||
|
# def socketio_domains_disconnect():
|
||||||
|
# None
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
socketio.run(app,host='0.0.0.0', port=9000, debug=False, cors_allowed_origins="*") #, logger=logger, engineio_logger=engineio_logger)
|
socketio.run(app,host='0.0.0.0', port=9000, debug=False, cors_allowed_origins="*") #, logger=logger, engineio_logger=engineio_logger)
|
||||||
|
|
Loading…
Reference in New Issue