1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-05-12 01:45:04 +00:00

Add group audit log 13 ()

* add group audit log

* Update mysql.sql

* optimize parameters

* update

* update

* update

* optimize

* code optimize

* update

* Update operation-logs.js

* update

* Update models.py

* update

---------

Co-authored-by: 孙永强 <11704063+s-yongqiang@user.noreply.gitee.com>
Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
awu0403 2025-03-28 17:51:29 +08:00 committed by GitHub
parent 29c8c12fa8
commit ebe1c54153
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 793 additions and 9 deletions

View File

@ -0,0 +1,21 @@
import { lang } from '../utils/constants';
import dayjs from 'dayjs';
dayjs.locale(lang);
class OrgGroupMemberAuditLog {
constructor(object) {
this.group_id = object.group_id;
this.group_name = object.group_name;
this.user_contact_email = object.user_contact_email;
this.user_email = object.user_email;
this.user_name = object.user_name;
this.operator_email = object.operator_email;
this.operator_name = object.operator_name;
this.operator_contact_email = object.operator_contact_email;
this.operation = object.operation;
this.time = dayjs(object.date).format('YYYY-MM-DD HH:mm:ss');
}
}
export default OrgGroupMemberAuditLog;

View File

@ -38,6 +38,7 @@ import OrgLogsFileAudit from './org-logs-file-audit';
import OrgLogsFileUpdate from './org-logs-file-update';
import OrgLogsPermAudit from './org-logs-perm-audit';
import OrgLogsFileTransfer from './org-logs-file-transfer';
import OrgLogsGroupMemberAudit from './org-logs-group-member-audit';
import OrgSAMLConfig from './org-saml-config';
import OrgSubscription from './org-subscription';
@ -128,6 +129,7 @@ class Org extends React.Component {
<OrgLogsFileUpdate path='file-update' />
<OrgLogsPermAudit path='perm-audit' />
<OrgLogsFileTransfer path='repo-transfer' />
<OrgLogsGroupMemberAudit path='group-member-audit' />
</OrgLogs>
{enableMultiADFS &&
<OrgSAMLConfig path={siteRoot + 'org/samlconfig/'}/>

View File

@ -0,0 +1,150 @@
import React from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { orgAdminAPI } from '../../utils/org-admin-api';
import { siteRoot, gettext, lang } from '../../utils/constants';
import { Utils } from '../../utils/utils';
import toaster from '../../components/toast';
import OrgGroupMemberAuditLog from '../../models/org-logs-group-member-audit';
import '../../css/org-logs.css';
import UserLink from './user-link';
import { Link } from '@gatsbyjs/reach-router';
dayjs.locale(lang);
class OrgLogsGroupMemberAudit extends React.Component {
constructor(props) {
super(props);
this.state = {
page: 1,
perPage: 25,
pageNext: false,
eventList: [],
};
}
componentDidMount() {
let page = this.state.page;
let perPage = this.state.perPage;
this.initData(page, perPage);
}
initData = (page, perPage) => {
orgAdminAPI.orgAdminListGroupInvite(page, perPage).then(res => {
let eventList = res.data.log_list.map(item => {
return new OrgGroupMemberAuditLog(item);
});
this.setState({
eventList: eventList,
pageNext: res.data.page_next,
page: res.data.page,
});
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
};
onChangePageNum = (e, num) => {
e.preventDefault();
let page = this.state.page;
let perPage = this.state.perPage;
if (num == 1) {
page = page + 1;
} else {
page = page - 1;
}
this.initData(page, perPage);
};
render() {
let eventList = this.state.eventList;
return (
<div className="cur-view-content">
<table>
<thead>
<tr>
<th width="20%">{gettext('User')}</th>
<th width="20%">{gettext('Group')}</th>
<th width="20%">{gettext('Operator')}</th>
<th width="20%">{gettext('Action')}</th>
<th width="20%">{gettext('Date')}</th>
</tr>
</thead>
<tbody>
{eventList.map((item, index) => {
return (
<GroupInviteItem
key={index}
groupEvent={item}
/>
);
})}
</tbody>
</table>
<div className="paginator">
{this.state.page != 1 && <a href="#" onClick={(e) => this.onChangePageNum(e, -1)}>{gettext('Previous')}</a>}
{(this.state.page != 1 && this.state.pageNext) && <span> | </span>}
{this.state.pageNext && <a href="#" onClick={(e) => this.onChangePageNum(e, 1)}>{gettext('Next')}</a>}
</div>
</div>
);
}
}
const propTypes = {
groupEvent: PropTypes.object.isRequired,
};
class GroupInviteItem extends React.Component {
constructor(props) {
super(props);
this.state = {
highlight: false,
};
}
handleMouseOver = () => {
this.setState({
highlight: true
});
};
handleMouseOut = () => {
this.setState({
highlight: false
});
};
getActionTextByEType = (operation) => {
if (operation.indexOf('group_member_add') != -1) {
return gettext('Add member');
} else if (operation.indexOf('group_member_delete') != -1) {
return gettext('Delete member');
} else {
return '';
}
};
render() {
let { groupEvent } = this.props;
return (
<tr className={this.state.highlight ? 'tr-highlight' : ''}
onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
<td>{<UserLink email={groupEvent.user_email} name={groupEvent.user_name} />}</td>
<td>{<Link to={`${siteRoot}org/groupadmin/${groupEvent.group_id}/`}>{groupEvent.group_name}</Link>}</td>
<td>{<UserLink email={groupEvent.operator_email} name={groupEvent.operator_name} />}</td>
<td>{this.getActionTextByEType(groupEvent.operation)}</td>
<td>{dayjs(groupEvent.time).fromNow()}</td>
</tr>
);
}
}
GroupInviteItem.propTypes = propTypes;
export default OrgLogsGroupMemberAudit;

View File

@ -40,7 +40,7 @@ class OrgLogs extends Component {
const { isExportExcelDialogOpen, logType } = this.state;
return (
<Fragment>
{this.props.currentTab === 'repo-transfer' ?
{this.props.currentTab === 'repo-transfer' || this.props.currentTab === 'group-member-audit' ?
<MainPanelTopbar />
:
<MainPanelTopbar>
@ -75,6 +75,12 @@ class OrgLogs extends Component {
to={siteRoot + 'org/logadmin/repo-transfer/'} title={gettext('Repo Transfer')}>{gettext('Repo Transfer')}
</Link>
</li>
<li className="nav-item" onClick={() => this.tabItemClick('group-member-audit')}>
<Link
className={`nav-link ${this.props.currentTab === 'group-member-audit' ? 'active' : ''}`}
to={siteRoot + 'org/logadmin/group-member-audit/'} title={gettext('Group Invite')}>{gettext('Group Invite')}
</Link>
</li>
</ul>
</div>
<div className="h-100 o-auto">

View File

@ -108,6 +108,8 @@ class Item extends Component {
case 'user_add': return gettext('Add User');
case 'user_delete': return gettext('Delete User');
case 'user_migrate': return gettext('Migrate User');
case 'group_member_add': return gettext('Add User to Group');
case 'group_member_delete': return gettext('Delete User from Group');
default: return '';
}
};
@ -193,6 +195,18 @@ class Item extends Component {
.replace('{user_to}', '<span class="font-weight-bold">' + detail.to + '</span>');
return detailText;
case 'group_member_add':
detailText = gettext('Added user {user} to group {group}')
.replace('{user}', '<span class="font-weight-bold">' + detail.user + '</span>')
.replace('{group}', '<span class="font-weight-bold">' + detail.name + '</span>');
return detailText;
case 'group_member_delete':
detailText = gettext('Deleted user {user} from group {group}')
.replace('{user}', '<span class="font-weight-bold">' + detail.user + '</span>')
.replace('{group}', '<span class="font-weight-bold">' + detail.name + '</span>');
return detailText;
default: return '';
}
};

View File

@ -68,7 +68,7 @@ import FileAccessLogs from './logs-page/file-access-logs';
import FileUpdateLogs from './logs-page/file-update-logs';
import SharePermissionLogs from './logs-page/share-permission-logs';
import FIleTransferLogs from './logs-page/file-transfer-log';
import GroupMemberAuditLogs from './logs-page/group-member-audit-logs';
import WebSettings from './web-settings/web-settings';
import Notifications from './notifications/notifications';
import FileScanRecords from './file-scan-records';
@ -253,6 +253,7 @@ class SysAdmin extends React.Component {
<LoginLogs path={siteRoot + 'sys/logs/login'} {...commonProps} />
<FileAccessLogs path={siteRoot + 'sys/logs/file-access'} {...commonProps} />
<FIleTransferLogs path={siteRoot + 'sys/logs/repo-transfer'} {...commonProps} />
<GroupMemberAuditLogs path={siteRoot + 'sys/logs/group-member-audit'} {...commonProps} />
<FileUpdateLogs path={siteRoot + 'sys/logs/file-update'} {...commonProps} />
<SharePermissionLogs path={siteRoot + 'sys/logs/share-permission'} {...commonProps} />
<AdminOperationLogs path={siteRoot + 'sys/admin-logs/operation'} {...commonProps} />

View File

@ -0,0 +1,222 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Link } from '@gatsbyjs/reach-router';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { seafileAPI } from '../../../utils/seafile-api';
import { gettext, siteRoot } from '../../../utils/constants';
import { Utils } from '../../../utils/utils';
import EmptyTip from '../../../components/empty-tip';
import Loading from '../../../components/loading';
import Paginator from '../../../components/paginator';
import MainPanelTopbar from '../main-panel-topbar';
import UserLink from '../user-link';
import LogsNav from './logs-nav';
dayjs.extend(relativeTime);
class Content extends Component {
getPreviousPage = () => {
this.props.getLogsByPage(this.props.currentPage - 1);
};
getNextPage = () => {
this.props.getLogsByPage(this.props.currentPage + 1);
};
render() {
const { loading, errorMsg, items, perPage, currentPage, hasNextPage } = this.props;
if (loading) {
return <Loading />;
} else if (errorMsg) {
return <p className="error text-center">{errorMsg}</p>;
} else {
const emptyTip = (
<EmptyTip text={gettext('No group invite logs')}>
</EmptyTip>
);
const table = (
<Fragment>
<table className="table-hover">
<thead>
<tr>
<th width="20%">{gettext('User')}</th>
<th width="20%">{gettext('Group')}</th>
<th width="20%">{gettext('Operator')}</th>
<th width="25%">{gettext('Action')}</th>
<th width="15%">{gettext('Date')}</th>
</tr>
</thead>
{items &&
<tbody>
{items.map((item, index) => {
return (<Item
key={index}
item={item}
/>);
})}
</tbody>
}
</table>
<Paginator
gotoPreviousPage={this.getPreviousPage}
gotoNextPage={this.getNextPage}
currentPage={currentPage}
hasNextPage={hasNextPage}
curPerPage={perPage}
resetPerPage={this.props.resetPerPage}
/>
</Fragment>
);
return items.length ? table : emptyTip;
}
}
}
Content.propTypes = {
loading: PropTypes.bool.isRequired,
errorMsg: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
getLogsByPage: PropTypes.func,
resetPerPage: PropTypes.func,
currentPage: PropTypes.number,
perPage: PropTypes.number,
pageInfo: PropTypes.object,
hasNextPage: PropTypes.bool,
};
class Item extends Component {
constructor(props) {
super(props);
this.state = {
isOpIconShown: false,
};
}
handleMouseOver = () => {
this.setState({
isOpIconShown: true
});
};
handleMouseOut = () => {
this.setState({
isOpIconShown: false
});
};
getGroupName = (item) => {
if (item.group_name) {
return <Link to={`${siteRoot}sys/groups/${item.group_id}/libraries/`}>{item.group_name}</Link>;
} else {
return gettext('Deleted');
}
};
getActionTextByEType = (operation) => {
if (operation.indexOf('group_member_add') != -1) {
return gettext('Add member');
} else if (operation.indexOf('group_member_delete') != -1) {
return gettext('Delete member');
} else {
return '';
}
};
render() {
let { item } = this.props;
return (
<tr onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
<td>{<UserLink email={item.user_email} name={item.user_name} />}</td>
<td>{this.getGroupName(item)}</td>
<td>{<UserLink email={item.operator_email} name={item.operator_name} />}</td>
<td>{this.getActionTextByEType(item.operation)}</td>
<td>{dayjs(item.date).fromNow()}</td>
</tr>
);
}
}
Item.propTypes = {
item: PropTypes.object.isRequired,
};
class GroupMemberAuditLogs extends Component {
constructor(props) {
super(props);
this.state = {
loading: true,
errorMsg: '',
logList: [],
perPage: 100,
currentPage: 1,
hasNextPage: false,
};
this.initPage = 1;
}
componentDidMount() {
let urlParams = (new URL(window.location)).searchParams;
const { currentPage, perPage } = this.state;
this.setState({
perPage: parseInt(urlParams.get('per_page') || perPage),
currentPage: parseInt(urlParams.get('page') || currentPage)
}, () => {
this.getLogsByPage(this.state.currentPage);
});
}
getLogsByPage = (page) => {
let { perPage } = this.state;
seafileAPI.sysAdminListGroupInviteLogs(page, perPage).then((res) => {
this.setState({
logList: res.data.group_invite_log_list,
loading: false,
currentPage: page,
hasNextPage: res.data.has_next_page,
});
}).catch((error) => {
this.setState({
loading: false,
errorMsg: Utils.getErrorMsg(error, true)
});
});
};
resetPerPage = (newPerPage) => {
this.setState({
perPage: newPerPage,
}, () => this.getLogsByPage(this.initPage));
};
render() {
let { logList, currentPage, perPage, hasNextPage } = this.state;
return (
<Fragment>
<MainPanelTopbar {...this.props} />
<div className="main-panel-center flex-row">
<div className="cur-view-container">
<LogsNav currentItem="groupMember" />
<div className="cur-view-content">
<Content
loading={this.state.loading}
errorMsg={this.state.errorMsg}
items={logList}
currentPage={currentPage}
perPage={perPage}
hasNextPage={hasNextPage}
getLogsByPage={this.getLogsByPage}
resetPerPage={this.resetPerPage}
/>
</div>
</div>
</div>
</Fragment>
);
}
}
export default GroupMemberAuditLogs;

View File

@ -17,6 +17,7 @@ class Nav extends React.Component {
{ name: 'fileUpdateLogs', urlPart: 'logs/file-update', text: gettext('File Update') },
{ name: 'sharePermissionLogs', urlPart: 'logs/share-permission', text: gettext('Permission') },
{ name: 'fileTransfer', urlPart: 'logs/repo-transfer', text: gettext('Repo Transfer') },
{ name: 'groupMember', urlPart: 'logs/group-member-audit', text: gettext('Group Member') },
];
}

View File

@ -481,6 +481,14 @@ class OrgAdminAPI {
}
// org admin logs
orgAdminListGroupInvite(page, perPage) {
let url = this.server + '/api/v2.1/org/admin/logs/group-member-audit/';
let params = {
page: page,
per_page: perPage
};
return this.req.get(url, { params: params });
}
orgAdminListFileTransfer(page, perPage) {
let url = this.server + '/api/v2.1/org/admin/logs/repo-transfer/';
let params = {

View File

@ -2235,6 +2235,15 @@ class SeafileAPI {
return this.req.get(url, { params: params });
}
sysAdminListGroupInviteLogs(page, perPage) {
const url = this.server + '/api/v2.1/admin/logs/group-member-audit/';
let params = {
page: page,
per_page: perPage
};
return this.req.get(url, { params: params });
}
}
let seafileAPI = new SeafileAPI();

View File

@ -22,6 +22,10 @@ GROUP_CREATE = 'group_create'
GROUP_TRANSFER = 'group_transfer'
# 'group_delete': {'id': group_id, 'name': group_name, 'owner': group_owner}
GROUP_DELETE = 'group_delete'
# 'group_invite': {'id': group_id, 'name': group_name, 'user': user}
GROUP_MEMBER_ADD = 'group_member_add'
# 'group_member_delete': {'id': group_id, 'name': group_name, 'user': user}
GROUP_MEMBER_DELETE = 'group_member_delete'
# 'user_add': {'email': new_user}
USER_ADD = 'user_add'
@ -32,8 +36,8 @@ USER_DELETE = 'user_delete'
USER_MIGRATE = 'user_migrate'
ADMIN_LOG_OPERATION_TYPE = (REPO_TRANSFER, REPO_DELETE,
GROUP_CREATE, GROUP_TRANSFER, GROUP_DELETE,
USER_ADD, USER_DELETE, USER_MIGRATE)
GROUP_CREATE, GROUP_TRANSFER, GROUP_DELETE, GROUP_MEMBER_ADD,
GROUP_MEMBER_DELETE, USER_ADD, USER_DELETE, USER_MIGRATE)
class AdminLogManager(models.Manager):

View File

@ -9,8 +9,10 @@ from rest_framework import status
from seaserv import seafile_api, ccnet_api
from seahub.admin_log.models import GROUP_MEMBER_ADD, GROUP_MEMBER_DELETE
from seahub.group.utils import get_group_member_info, is_group_member, get_group_members_info
from seahub.group.signals import add_user_to_group
from seahub.admin_log.signals import admin_operation
from seahub.avatar.settings import AVATAR_DEFAULT_SIZE
from seahub.base.accounts import User
from seahub.base.templatetags.seahub_tags import email2nickname
@ -99,6 +101,11 @@ class AdminGroupMembers(APIView):
error_msg = 'Group %d not found.' % group_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
is_org = ccnet_api.is_org_group(group_id)
if is_org:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
emails = request.POST.getlist('email', '')
if not emails:
error_msg = 'Email invalid.'
@ -146,6 +153,14 @@ class AdminGroupMembers(APIView):
group_id=group_id,
added_user=email)
admin_op_detail = {
'id': group_id,
'name': group.group_name,
'user': email
}
admin_operation.send(sender=None, admin_name=request.user.username,
operation=GROUP_MEMBER_ADD, detail=admin_op_detail)
return Response(result)
@ -172,6 +187,11 @@ class AdminGroupMember(APIView):
error_msg = 'Group %d not found.' % group_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
is_org = ccnet_api.is_org_group(group_id)
if is_org:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
try:
User.objects.get(email=email)
except User.DoesNotExist:
@ -222,6 +242,11 @@ class AdminGroupMember(APIView):
error_msg = 'Group %d not found.' % group_id
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
is_org = ccnet_api.is_org_group(group_id)
if is_org:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
# delete member from group
try:
if not is_group_member(group_id, email):
@ -239,6 +264,14 @@ class AdminGroupMember(APIView):
ccnet_api.group_remove_member(group_id, group.creator_name, email)
# remove repo-group share info of all 'email' owned repos
seafile_api.remove_group_repos_by_owner(group_id, email)
admin_op_detail = {
'id': group_id,
'name': group.group_name,
'user': email
}
admin_operation.send(sender=None, admin_name=request.user.username,
operation=GROUP_MEMBER_DELETE, detail=admin_op_detail)
except Exception as e:
logger.error(e)
error_msg = 'Internal Server Error'

View File

@ -20,7 +20,7 @@ from seahub.utils import get_file_audit_events, generate_file_audit_event_type,
get_file_update_events, get_perm_audit_events, is_valid_email
from seahub.utils.timeutils import datetime_to_isoformat_timestr, utc_datetime_to_isoformat_timestr
from seahub.utils.repo import is_valid_repo_id_format
from seahub.base.models import RepoTransfer
from seahub.base.models import RepoTransfer, GroupMemberAudit
logger = logging.getLogger(__name__)
@ -564,3 +564,107 @@ class AdminLogsFileTransferLogs(APIView):
}
return Response(resp)
class AdminLogGroupMemberAuditLogs(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser, IsProVersion)
throttle_classes = (UserRateThrottle,)
def get(self, request):
""" Get all group member audit logs.
Permission checking:
1. only admin can perform this action.
"""
if not request.user.admin_permissions.can_view_user_log():
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
try:
current_page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '100'))
except ValueError:
current_page = 1
per_page = 100
start = per_page * (current_page - 1)
limit = per_page
if start < 0:
error_msg = 'start invalid'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
if limit < 0:
error_msg = 'limit invalid'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
events = GroupMemberAudit.objects.all().order_by('-timestamp')[start:start+limit+1]
if len(events) > limit:
has_next_page = True
events = events[:limit]
else:
has_next_page = False
# Use dict to reduce memcache fetch cost in large for-loop.
nickname_dict = {}
contact_email_dict = {}
group_name_dict = {}
user_email_set = set()
group_id_set = set()
for event in events:
if is_valid_email(event.user):
user_email_set.add(event.user)
if is_valid_email(event.operator):
user_email_set.add(event.operator)
if event.group_id not in group_id_set:
group_id_set.add(event.group_id)
for e in user_email_set:
if e not in nickname_dict:
nickname_dict[e] = email2nickname(e)
if e not in contact_email_dict:
contact_email_dict[e] = email2contact_email(e)
for group_id in group_id_set:
if group_id not in group_name_dict:
group = ccnet_api.get_group(int(group_id))
if group:
group_name_dict[group_id] = group.group_name
events_info = []
for ev in events:
data = {
'user_email': '',
'user_name': '',
'user_contact_email': '',
'group_id': '',
'group_name': '',
'operator_email': '',
'operator_name': '',
'operator_contact_email': '',
}
user_email = ev.user
data['user_email'] = user_email
data['user_name'] = nickname_dict.get(user_email, '')
data['user_contact_email'] = contact_email_dict.get(user_email, '')
operator_email = ev.operator
data['operator_email'] = operator_email
data['operator_name'] = nickname_dict.get(operator_email, '')
data['operator_contact_email'] = contact_email_dict.get(operator_email, '')
data['date'] = datetime_to_isoformat_timestr(ev.timestamp)
data['operation'] = ev.operation
data['group_id'] = ev.group_id
data['group_name'] = group_name_dict.get(ev.group_id, '')
events_info.append(data)
resp = {
'group_invite_log_list': events_info,
'has_next_page': has_next_page,
}
return Response(resp)

View File

@ -25,12 +25,15 @@ from seahub.group.models import GroupInviteLinkModel
from seahub.utils.ms_excel import write_xls
from seahub.utils.error_msg import file_type_error_msg
from seahub.base.accounts import User
from seahub.base.models import GROUP_MEMBER_ADD, GROUP_MEMBER_DELETE
from seahub.group.signals import add_user_to_group
from seahub.group.views import group_invite
from seahub.organizations.views import get_org_id_by_group
from seahub.group.utils import is_group_member, is_group_admin, \
is_group_owner, is_group_admin_or_owner, get_group_member_info
from seahub.profile.models import Profile
from seahub.settings import MULTI_TENANCY
from seahub.signals import group_member_audit
from .utils import api_check_group
@ -242,12 +245,20 @@ class GroupMember(APIView):
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
username = request.user.username
org_id = get_org_id_by_group(group_id)
# user leave group
if username == email:
try:
ccnet_api.quit_group(group_id, username)
# remove repo-group share info of all 'email' owned repos
seafile_api.remove_group_repos_by_owner(group_id, email)
# add group invite log
group_member_audit.send(sender=None,
org_id=org_id if org_id else -1,
group_id=group_id,
users=[email],
operator=username,
operation=GROUP_MEMBER_DELETE)
return Response({'success': True})
except SearpcError as e:
logger.error(e)
@ -260,6 +271,12 @@ class GroupMember(APIView):
# group owner can delete all group member
ccnet_api.group_remove_member(group_id, username, email)
seafile_api.remove_group_repos_by_owner(group_id, email)
group_member_audit.send(sender=None,
org_id=org_id if org_id else -1,
group_id=group_id,
users=[email],
operator=username,
operation=GROUP_MEMBER_DELETE)
return Response({'success': True})
elif is_group_admin(group_id, username):
@ -267,6 +284,12 @@ class GroupMember(APIView):
if not is_group_admin_or_owner(group_id, email):
ccnet_api.group_remove_member(group_id, username, email)
seafile_api.remove_group_repos_by_owner(group_id, email)
group_member_audit.send(sender=None,
org_id=org_id if org_id else -1,
group_id=group_id,
users=[email],
operator=username,
operation=GROUP_MEMBER_DELETE)
return Response({'success': True})
else:
error_msg = 'Permission denied.'
@ -355,11 +378,13 @@ class GroupMembersBulk(APIView):
emails_need_add.append(email)
# Add user to group.
emails_added = []
for email in emails_need_add:
try:
ccnet_api.group_add_member(group_id, username, email)
member_info = get_group_member_info(request, group_id, email)
result['success'].append(member_info)
emails_added.append(email)
except SearpcError as e:
logger.error(e)
result['failed'].append({
@ -371,6 +396,13 @@ class GroupMembersBulk(APIView):
group_staff=username,
group_id=group_id,
added_user=email)
# add group invite log
group_member_audit.send(sender=None,
org_id=org_id if org_id else -1,
group_id=group_id,
users=emails_added,
operator=username,
operation=GROUP_MEMBER_ADD)
return Response(result)
@ -503,11 +535,13 @@ class GroupMembersImport(APIView):
emails_need_add.append(email)
# Add user to group.
emails_added = []
for email in emails_need_add:
try:
ccnet_api.group_add_member(group_id, username, email)
member_info = get_group_member_info(request, group_id, email)
result['success'].append(member_info)
emails_added.append(email)
except SearpcError as e:
logger.error(e)
result['failed'].append({
@ -519,6 +553,14 @@ class GroupMembersImport(APIView):
group_staff=username,
group_id=group_id,
added_user=email)
group_member_audit.send(sender=None,
org_id=org_id if org_id else -1,
group_id=group_id,
users=emails_added,
operator=username,
operation=GROUP_MEMBER_ADD)
return Response(result)

View File

@ -10,6 +10,7 @@ from seaserv import seafile_api
from seahub.auth.signals import user_logged_in
from seahub.organizations.signals import org_last_activity
from seahub.signals import group_member_audit
from seahub.utils import within_time_range, gen_token, \
normalize_file_path, normalize_dir_path
from seahub.utils.timeutils import datetime_to_isoformat_timestr
@ -475,3 +476,42 @@ class RepoTransfer(models.Model):
class Meta:
db_table = 'RepoTransfer'
GROUP_MEMBER_ADD = 'group_member_add'
GROUP_MEMBER_DELETE = 'group_member_delete'
class GroupMemberAudit(models.Model):
org_id = models.IntegerField(db_index=True)
group_id = models.IntegerField(db_index=True)
user = models.EmailField(db_index=True)
operator = models.CharField(max_length=255, db_index=True)
operation = models.CharField(max_length=128)
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
class Meta:
db_table = 'group_member_audit'
###### signal handler ###############
from django.dispatch import receiver
@receiver(group_member_audit)
def add_group_invite_log(sender, org_id, group_id, users, operator, operation, **kwargs):
if operation not in [GROUP_MEMBER_ADD, GROUP_MEMBER_DELETE]:
return
group_member_audit_list = []
for user in users:
group_member_audit_list.append(GroupMemberAudit(
org_id=org_id,
group_id=group_id,
user=user,
operator=operator,
operation=operation
))
GroupMemberAudit.objects.bulk_create(group_member_audit_list)

View File

@ -16,6 +16,7 @@ from seaserv import ccnet_threaded_rpc, ccnet_api, get_group
from seahub.auth import REDIRECT_FIELD_NAME
from seahub.base.decorators import sys_staff_required, require_POST
from seahub.base.models import GROUP_MEMBER_ADD
from seahub.group.utils import validate_group_name, BadGroupNameError, \
ConflictGroupNameError, is_group_member
from seahub.group.models import GroupInviteLinkModel
@ -23,6 +24,7 @@ from seahub.settings import SITE_ROOT, SERVICE_URL, MULTI_TENANCY
from seahub.utils import send_html_email, is_org_context, \
get_site_name, render_error
from seahub.share.models import ExtraGroupsSharePermission
from seahub.signals import group_member_audit
# Get an instance of a logger
@ -195,6 +197,12 @@ def group_invite(request, token):
try:
ccnet_api.group_add_member(group_invite_link.group_id, group_invite_link.created_by, email)
group_member_audit.send(sender=None,
org_id=-1,
group_id=group_invite_link.group_id,
users=[email],
operator=group_invite_link.created_by,
operation=GROUP_MEMBER_ADD)
except Exception as e:
logger.error(f'group invite add user failed. {e}')
return render_error(request, 'Internal Server Error')

View File

@ -17,12 +17,11 @@ from seahub.api2.endpoints.utils import get_user_name_dict, \
get_user_contact_email_dict, get_repo_dict, get_group_dict
from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email
from seahub.base.models import RepoTransfer
from seahub.base.models import RepoTransfer, GroupMemberAudit
from seahub.utils import EVENTS_ENABLED, get_file_audit_events, get_file_update_events, get_perm_audit_events, is_valid_email
from seahub.utils.timeutils import timestamp_to_isoformat_timestr, datetime_to_isoformat_timestr
from seahub.organizations.api.permissions import IsOrgAdmin
from seahub.organizations.views import org_user_exists
from seahub.organizations.api.utils import update_log_perm_audit_type
logger = logging.getLogger(__name__)
@ -401,3 +400,101 @@ class OrgAdminLogsFileTransfer(APIView):
'page': page,
'page_next': page_next,
})
class OrgAdminLogsGroupMemberAudit(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
throttle_classes = (UserRateThrottle,)
permission_classes = (IsProVersion, IsOrgAdmin)
def get(self, request):
"""List organization group member audit logs
"""
try:
page = int(request.GET.get('page', '1'))
per_page = int(request.GET.get('per_page', '25'))
except ValueError:
page = 1
per_page = 25
start = per_page * (page - 1)
limit = per_page
org_id = request.user.org.org_id
events = GroupMemberAudit.objects.filter(org_id=org_id).all().order_by('-timestamp')[start:start+limit+1]
if len(events) > limit:
page_next = True
events = events[:limit]
else:
page_next = False
event_list = []
if not events:
return Response({
'log_list': event_list,
'page': page,
'page_next': False
})
# Use dict to reduce memcache fetch cost in large for-loop.
nickname_dict = {}
contact_email_dict = {}
group_name_dict = {}
user_email_set = set()
group_id_set = set()
for event in events:
if is_valid_email(event.user):
user_email_set.add(event.user)
if is_valid_email(event.operator):
user_email_set.add(event.operator)
if event.group_id not in group_id_set:
group_id_set.add(event.group_id)
for e in user_email_set:
if e not in nickname_dict:
nickname_dict[e] = email2nickname(e)
if e not in contact_email_dict:
contact_email_dict[e] = email2contact_email(e)
for group_id in group_id_set:
if group_id not in group_name_dict:
group = ccnet_api.get_group(int(group_id))
if group:
group_name_dict[group_id] = group.group_name
event_list = []
for ev in events:
data = {
'user_email': '',
'user_name': '',
'user_contact_email': '',
'group_id': '',
'group_name': '',
'operator_email': '',
'operator_name': '',
'operator_contact_email': '',
}
user_email = ev.user
data['user_email'] = user_email
data['user_name'] = nickname_dict.get(user_email, '')
data['user_contact_email'] = contact_email_dict.get(user_email, '')
operator_email = ev.operator
data['operator_email'] = operator_email
data['operator_name'] = nickname_dict.get(operator_email, '')
data['operator_contact_email'] = contact_email_dict.get(operator_email, '')
data['group_id'] = ev.group_id
data['group_name'] = group_name_dict.get(ev.group_id, '')
data['operation'] = ev.operation
data['date'] = datetime_to_isoformat_timestr(ev.timestamp)
event_list.append(data)
return Response({
'log_list': event_list,
'page': page,
'page_next': page_next,
})

View File

@ -20,7 +20,8 @@ from .api.admin.trash_libraries import OrgAdminTrashLibraries, OrgAdminTrashLibr
from .api.admin.info import OrgAdminInfo
from .api.admin.links import OrgAdminLinks, OrgAdminLink
from .api.admin.web_settings import OrgAdminWebSettings
from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit, OrgAdminLogsFileTransfer
from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit, \
OrgAdminLogsFileTransfer, OrgAdminLogsGroupMemberAudit
from .api.admin.user_repos import OrgAdminUserRepos, OrgAdminUserBesharedRepos
from .api.admin.devices import OrgAdminDevices, OrgAdminDevicesErrors
@ -103,6 +104,7 @@ urlpatterns = [
path('admin/logs/file-update/', OrgAdminLogsFileUpdate.as_view(), name='api-v2.1-org-admin-logs-file-update'),
path('admin/logs/repo-permission/', OrgAdminLogsPermAudit.as_view(), name='api-v2.1-org-admin-logs-repo-permission'),
path('admin/logs/repo-transfer/', OrgAdminLogsFileTransfer.as_view(), name='api-v2.1-org-admin-logs-repo-transfer'),
path('admin/logs/group-member-audit/', OrgAdminLogsGroupMemberAudit.as_view(), name='api-v2.1-org-admin-logs-group-member-audit'),
path('<int:org_id>/admin/departments/', OrgAdminDepartments.as_view(), name='api-v2.1-org-admin-departments'),
path('<int:org_id>/admin/logs/export-excel/', OrgLogsExport.as_view(), name='api-v2.1-org-logs-export-excel'),
path('admin/log/export-excel/', org_log_export_excel, name='org_log_export_excel'),

View File

@ -36,6 +36,7 @@ urlpatterns = [
path('logadmin/file-update/', react_fake_view, name='org_log_file_update'),
path('logadmin/perm-audit/', react_fake_view, name='org_log_perm_audit'),
path('logadmin/repo-transfer/', react_fake_view, name='org_log_file_transfer'),
path('logadmin/group-member-audit/', react_fake_view, name='org_log_group_member_audit'),
path('info/', react_fake_view, name='org_info'),
path('settings/', react_fake_view, name='org_settings'),

View File

@ -11,3 +11,4 @@ upload_file_successful = Signal()
upload_folder_successful = Signal()
comment_file_successful = Signal()
institution_deleted = Signal()
group_member_audit = Signal()

View File

@ -194,7 +194,7 @@ from seahub.api2.endpoints.admin.file_scan_records import AdminFileScanRecords
from seahub.api2.endpoints.admin.notifications import AdminNotificationsView
from seahub.api2.endpoints.admin.sys_notifications import AdminSysNotificationsView, AdminSysNotificationView
from seahub.api2.endpoints.admin.logs import AdminLogsLoginLogs, AdminLogsFileAccessLogs, AdminLogsFileUpdateLogs, \
AdminLogsSharePermissionLogs, AdminLogsFileTransferLogs
AdminLogsSharePermissionLogs, AdminLogsFileTransferLogs, AdminLogGroupMemberAuditLogs
from seahub.api2.endpoints.admin.terms_and_conditions import AdminTermsAndConditions, AdminTermAndCondition
from seahub.api2.endpoints.admin.work_weixin import AdminWorkWeixinDepartments, \
AdminWorkWeixinDepartmentMembers, AdminWorkWeixinUsersBatch, AdminWorkWeixinDepartmentsImport
@ -699,6 +699,7 @@ urlpatterns = [
re_path(r'^api/v2.1/admin/logs/file-update-logs/$', AdminLogsFileUpdateLogs.as_view(), name='api-v2.1-admin-logs-file-update-logs'),
re_path(r'^api/v2.1/admin/logs/share-permission-logs/$', AdminLogsSharePermissionLogs.as_view(), name='api-v2.1-admin-logs-share-permission-logs'),
re_path(r'^api/v2.1/admin/logs/repo-transfer-logs/$', AdminLogsFileTransferLogs.as_view(), name='api-v2.1-admin-logs-repo-transfer-logs'),
re_path(r'^api/v2.1/admin/logs/group-member-audit/$', AdminLogGroupMemberAuditLogs.as_view(), name='api-v2.1-admin-logs-group-member-audit'),
## admin::admin logs
re_path(r'^api/v2.1/admin/admin-logs/$', AdminOperationLogs.as_view(), name='api-v2.1-admin-admin-operation-logs'),
@ -875,6 +876,7 @@ urlpatterns = [
path('sys/logs/file-update/', sysadmin_react_fake_view, name="sys_logs_file_update"),
path('sys/logs/share-permission/', sysadmin_react_fake_view, name="sys_logs_share_permission"),
path('sys/logs/repo-transfer/', sysadmin_react_fake_view, name="sys_logs_file_transfer"),
path('sys/logs/group-member-audit/', sysadmin_react_fake_view, name="sys_logs_group_member_audit"),
path('sys/admin-logs/operation/', sysadmin_react_fake_view, name="sys_admin_logs_operation"),
path('sys/admin-logs/login/', sysadmin_react_fake_view, name="sys_admin_logs_login"),
path('sys/organizations/', sysadmin_react_fake_view, name="sys_organizations"),

View File

@ -1624,4 +1624,20 @@ CREATE TABLE `org_last_active_time` (
PRIMARY KEY (`id`),
UNIQUE KEY `org_id` (`org_id`),
KEY `ix_org_last_active_time_org_id` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `group_member_audit` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`org_id` int(11) NOT NULL,
`operator` varchar(255) NOT NULL,
`user` varchar(255) NOT NULL,
`group_id` int(11) NOT NULL,
`operation` varchar(128) NOT NULL,
`timestamp` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_group_member_audit_org_id` (`org_id`),
KEY `idx_group_member_audit_timestamp` (`timestamp`),
KEY `idx_group_member_audit_operator` (`operator`),
KEY `idx_group_member_audit_user` (`user`),
KEY `idx_group_member_audit_group_id` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;