##// END OF EJS Templates
artifacts: added reading of metadata and define basic audit-log entries for add/delete artifact
marcink -
r3679:4cc4558e new-ui
parent child Browse files
Show More
@@ -1,211 +1,224 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2019 RhodeCode GmbH
3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import os
21 import os
22 import time
22 import time
23 import shutil
23 import shutil
24 import hashlib
24 import hashlib
25
25
26 from rhodecode.lib.ext_json import json
26 from rhodecode.lib.ext_json import json
27 from rhodecode.apps.file_store import utils
27 from rhodecode.apps.file_store import utils
28 from rhodecode.apps.file_store.extensions import resolve_extensions
28 from rhodecode.apps.file_store.extensions import resolve_extensions
29 from rhodecode.apps.file_store.exceptions import FileNotAllowedException
29 from rhodecode.apps.file_store.exceptions import FileNotAllowedException
30
30
31 METADATA_VER = 'v1'
31 METADATA_VER = 'v1'
32
32
33
33
34 class LocalFileStorage(object):
34 class LocalFileStorage(object):
35
35
36 @classmethod
36 @classmethod
37 def resolve_name(cls, name, directory):
37 def resolve_name(cls, name, directory):
38 """
38 """
39 Resolves a unique name and the correct path. If a filename
39 Resolves a unique name and the correct path. If a filename
40 for that path already exists then a numeric prefix with values > 0 will be
40 for that path already exists then a numeric prefix with values > 0 will be
41 added, for example test.jpg -> test-1.jpg etc. initially file would have 0 prefix.
41 added, for example test.jpg -> test-1.jpg etc. initially file would have 0 prefix.
42
42
43 :param name: base name of file
43 :param name: base name of file
44 :param directory: absolute directory path
44 :param directory: absolute directory path
45 """
45 """
46
46
47 basename, ext = os.path.splitext(name)
47 basename, ext = os.path.splitext(name)
48 counter = 0
48 counter = 0
49 while True:
49 while True:
50 name = '%s-%d%s' % (basename, counter, ext)
50 name = '%s-%d%s' % (basename, counter, ext)
51
51
52 # sub_store prefix to optimize disk usage, e.g some_path/ab/final_file
52 # sub_store prefix to optimize disk usage, e.g some_path/ab/final_file
53 sub_store = cls._sub_store_from_filename(basename)
53 sub_store = cls._sub_store_from_filename(basename)
54 sub_store_path = os.path.join(directory, sub_store)
54 sub_store_path = os.path.join(directory, sub_store)
55 if not os.path.exists(sub_store_path):
55 if not os.path.exists(sub_store_path):
56 os.makedirs(sub_store_path)
56 os.makedirs(sub_store_path)
57
57
58 path = os.path.join(sub_store_path, name)
58 path = os.path.join(sub_store_path, name)
59 if not os.path.exists(path):
59 if not os.path.exists(path):
60 return name, path
60 return name, path
61 counter += 1
61 counter += 1
62
62
63 @classmethod
63 @classmethod
64 def _sub_store_from_filename(cls, filename):
64 def _sub_store_from_filename(cls, filename):
65 return filename[:2]
65 return filename[:2]
66
66
67 @classmethod
67 @classmethod
68 def calculate_path_hash(cls, file_path):
68 def calculate_path_hash(cls, file_path):
69 """
69 """
70 Efficient calculation of file_path sha256 sum
70 Efficient calculation of file_path sha256 sum
71
71
72 :param file_path:
72 :param file_path:
73 :return: sha256sum
73 :return: sha256sum
74 """
74 """
75 digest = hashlib.sha256()
75 digest = hashlib.sha256()
76 with open(file_path, 'rb') as f:
76 with open(file_path, 'rb') as f:
77 for chunk in iter(lambda: f.read(1024 * 100), b""):
77 for chunk in iter(lambda: f.read(1024 * 100), b""):
78 digest.update(chunk)
78 digest.update(chunk)
79
79
80 return digest.hexdigest()
80 return digest.hexdigest()
81
81
82 def __init__(self, base_path, extension_groups=None):
82 def __init__(self, base_path, extension_groups=None):
83
83
84 """
84 """
85 Local file storage
85 Local file storage
86
86
87 :param base_path: the absolute base path where uploads are stored
87 :param base_path: the absolute base path where uploads are stored
88 :param extension_groups: extensions string
88 :param extension_groups: extensions string
89 """
89 """
90
90
91 extension_groups = extension_groups or ['any']
91 extension_groups = extension_groups or ['any']
92 self.base_path = base_path
92 self.base_path = base_path
93 self.extensions = resolve_extensions([], groups=extension_groups)
93 self.extensions = resolve_extensions([], groups=extension_groups)
94
94
95 def store_path(self, filename):
95 def store_path(self, filename):
96 """
96 """
97 Returns absolute file path of the filename, joined to the
97 Returns absolute file path of the filename, joined to the
98 base_path.
98 base_path.
99
99
100 :param filename: base name of file
100 :param filename: base name of file
101 """
101 """
102 sub_store = self._sub_store_from_filename(filename)
102 sub_store = self._sub_store_from_filename(filename)
103 return os.path.join(self.base_path, sub_store, filename)
103 return os.path.join(self.base_path, sub_store, filename)
104
104
105 def delete(self, filename):
105 def delete(self, filename):
106 """
106 """
107 Deletes the filename. Filename is resolved with the
107 Deletes the filename. Filename is resolved with the
108 absolute path based on base_path. If file does not exist,
108 absolute path based on base_path. If file does not exist,
109 returns **False**, otherwise **True**
109 returns **False**, otherwise **True**
110
110
111 :param filename: base name of file
111 :param filename: base name of file
112 """
112 """
113 if self.exists(filename):
113 if self.exists(filename):
114 os.remove(self.store_path(filename))
114 os.remove(self.store_path(filename))
115 return True
115 return True
116 return False
116 return False
117
117
118 def exists(self, filename):
118 def exists(self, filename):
119 """
119 """
120 Checks if file exists. Resolves filename's absolute
120 Checks if file exists. Resolves filename's absolute
121 path based on base_path.
121 path based on base_path.
122
122
123 :param filename: base name of file
123 :param filename: base name of file
124 """
124 """
125 return os.path.exists(self.store_path(filename))
125 return os.path.exists(self.store_path(filename))
126
126
127 def filename_allowed(self, filename, extensions=None):
127 def filename_allowed(self, filename, extensions=None):
128 """Checks if a filename has an allowed extension
128 """Checks if a filename has an allowed extension
129
129
130 :param filename: base name of file
130 :param filename: base name of file
131 :param extensions: iterable of extensions (or self.extensions)
131 :param extensions: iterable of extensions (or self.extensions)
132 """
132 """
133 _, ext = os.path.splitext(filename)
133 _, ext = os.path.splitext(filename)
134 return self.extension_allowed(ext, extensions)
134 return self.extension_allowed(ext, extensions)
135
135
136 def extension_allowed(self, ext, extensions=None):
136 def extension_allowed(self, ext, extensions=None):
137 """
137 """
138 Checks if an extension is permitted. Both e.g. ".jpg" and
138 Checks if an extension is permitted. Both e.g. ".jpg" and
139 "jpg" can be passed in. Extension lookup is case-insensitive.
139 "jpg" can be passed in. Extension lookup is case-insensitive.
140
140
141 :param ext: extension to check
141 :param ext: extension to check
142 :param extensions: iterable of extensions to validate against (or self.extensions)
142 :param extensions: iterable of extensions to validate against (or self.extensions)
143 """
143 """
144
144
145 extensions = extensions or self.extensions
145 extensions = extensions or self.extensions
146 if not extensions:
146 if not extensions:
147 return True
147 return True
148 if ext.startswith('.'):
148 if ext.startswith('.'):
149 ext = ext[1:]
149 ext = ext[1:]
150 return ext.lower() in extensions
150 return ext.lower() in extensions
151
151
152 def save_file(self, file_obj, filename, directory=None, extensions=None,
152 def save_file(self, file_obj, filename, directory=None, extensions=None,
153 extra_metadata=None, **kwargs):
153 extra_metadata=None, **kwargs):
154 """
154 """
155 Saves a file object to the uploads location.
155 Saves a file object to the uploads location.
156 Returns the resolved filename, i.e. the directory +
156 Returns the resolved filename, i.e. the directory +
157 the (randomized/incremented) base name.
157 the (randomized/incremented) base name.
158
158
159 :param file_obj: **cgi.FieldStorage** object (or similar)
159 :param file_obj: **cgi.FieldStorage** object (or similar)
160 :param filename: original filename
160 :param filename: original filename
161 :param directory: relative path of sub-directory
161 :param directory: relative path of sub-directory
162 :param extensions: iterable of allowed extensions, if not default
162 :param extensions: iterable of allowed extensions, if not default
163 :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix
163 :param extra_metadata: extra JSON metadata to store next to the file with .meta suffix
164 """
164 """
165
165
166 extensions = extensions or self.extensions
166 extensions = extensions or self.extensions
167
167
168 if not self.filename_allowed(filename, extensions):
168 if not self.filename_allowed(filename, extensions):
169 raise FileNotAllowedException()
169 raise FileNotAllowedException()
170
170
171 if directory:
171 if directory:
172 dest_directory = os.path.join(self.base_path, directory)
172 dest_directory = os.path.join(self.base_path, directory)
173 else:
173 else:
174 dest_directory = self.base_path
174 dest_directory = self.base_path
175
175
176 if not os.path.exists(dest_directory):
176 if not os.path.exists(dest_directory):
177 os.makedirs(dest_directory)
177 os.makedirs(dest_directory)
178
178
179 filename = utils.uid_filename(filename)
179 filename = utils.uid_filename(filename)
180
180
181 # resolve also produces special sub-dir for file optimized store
181 # resolve also produces special sub-dir for file optimized store
182 filename, path = self.resolve_name(filename, dest_directory)
182 filename, path = self.resolve_name(filename, dest_directory)
183 stored_file_dir = os.path.dirname(path)
183 stored_file_dir = os.path.dirname(path)
184
184
185 file_obj.seek(0)
185 file_obj.seek(0)
186
186
187 with open(path, "wb") as dest:
187 with open(path, "wb") as dest:
188 shutil.copyfileobj(file_obj, dest)
188 shutil.copyfileobj(file_obj, dest)
189
189
190 metadata = {}
190 metadata = {}
191 if extra_metadata:
191 if extra_metadata:
192 metadata = extra_metadata
192 metadata = extra_metadata
193
193
194 size = os.stat(path).st_size
194 size = os.stat(path).st_size
195 file_hash = self.calculate_path_hash(path)
195 file_hash = self.calculate_path_hash(path)
196
196
197 metadata.update(
197 metadata.update(
198 {"filename": filename,
198 {"filename": filename,
199 "size": size,
199 "size": size,
200 "time": time.time(),
200 "time": time.time(),
201 "sha256": file_hash,
201 "sha256": file_hash,
202 "meta_ver": METADATA_VER})
202 "meta_ver": METADATA_VER})
203
203
204 filename_meta = filename + '.meta'
204 filename_meta = filename + '.meta'
205 with open(os.path.join(stored_file_dir, filename_meta), "wb") as dest_meta:
205 with open(os.path.join(stored_file_dir, filename_meta), "wb") as dest_meta:
206 dest_meta.write(json.dumps(metadata))
206 dest_meta.write(json.dumps(metadata))
207
207
208 if directory:
208 if directory:
209 filename = os.path.join(directory, filename)
209 filename = os.path.join(directory, filename)
210
210
211 return filename, metadata
211 return filename, metadata
212
213 def get_metadata(self, filename):
214 """
215 Reads JSON stored metadata for a file
216
217 :param filename:
218 :return:
219 """
220 filename = self.store_path(filename)
221 filename_meta = filename + '.meta'
222
223 with open(filename_meta, "rb") as source_meta:
224 return json.loads(source_meta.read())
@@ -1,144 +1,144 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2019 RhodeCode GmbH
3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 import logging
20 import logging
21
21
22 from pyramid.view import view_config
22 from pyramid.view import view_config
23 from pyramid.response import FileResponse
23 from pyramid.response import FileResponse
24 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
24 from pyramid.httpexceptions import HTTPFound, HTTPNotFound
25
25
26 from rhodecode.apps._base import BaseAppView
26 from rhodecode.apps._base import BaseAppView
27 from rhodecode.apps.file_store import utils
27 from rhodecode.apps.file_store import utils
28 from rhodecode.apps.file_store.exceptions import (
28 from rhodecode.apps.file_store.exceptions import (
29 FileNotAllowedException, FileOverSizeException)
29 FileNotAllowedException, FileOverSizeException)
30
30
31 from rhodecode.lib import helpers as h
31 from rhodecode.lib import helpers as h
32 from rhodecode.lib import audit_logger
32 from rhodecode.lib import audit_logger
33 from rhodecode.lib.auth import (CSRFRequired, NotAnonymous, HasRepoPermissionAny, HasRepoGroupPermissionAny)
33 from rhodecode.lib.auth import (CSRFRequired, NotAnonymous, HasRepoPermissionAny, HasRepoGroupPermissionAny)
34 from rhodecode.model.db import Session, FileStore
34 from rhodecode.model.db import Session, FileStore
35
35
36 log = logging.getLogger(__name__)
36 log = logging.getLogger(__name__)
37
37
38
38
39 class FileStoreView(BaseAppView):
39 class FileStoreView(BaseAppView):
40 upload_key = 'store_file'
40 upload_key = 'store_file'
41
41
42 def load_default_context(self):
42 def load_default_context(self):
43 c = self._get_local_tmpl_context()
43 c = self._get_local_tmpl_context()
44 self.storage = utils.get_file_storage(self.request.registry.settings)
44 self.storage = utils.get_file_storage(self.request.registry.settings)
45 return c
45 return c
46
46
47 @NotAnonymous()
47 @NotAnonymous()
48 @CSRFRequired()
48 @CSRFRequired()
49 @view_config(route_name='upload_file', request_method='POST', renderer='json_ext')
49 @view_config(route_name='upload_file', request_method='POST', renderer='json_ext')
50 def upload_file(self):
50 def upload_file(self):
51 self.load_default_context()
51 self.load_default_context()
52 file_obj = self.request.POST.get(self.upload_key)
52 file_obj = self.request.POST.get(self.upload_key)
53
53
54 if file_obj is None:
54 if file_obj is None:
55 return {'store_fid': None,
55 return {'store_fid': None,
56 'access_path': None,
56 'access_path': None,
57 'error': '{} data field is missing'.format(self.upload_key)}
57 'error': '{} data field is missing'.format(self.upload_key)}
58
58
59 if not hasattr(file_obj, 'filename'):
59 if not hasattr(file_obj, 'filename'):
60 return {'store_fid': None,
60 return {'store_fid': None,
61 'access_path': None,
61 'access_path': None,
62 'error': 'filename cannot be read from the data field'}
62 'error': 'filename cannot be read from the data field'}
63
63
64 filename = file_obj.filename
64 filename = file_obj.filename
65
65
66 metadata = {
66 metadata = {
67 'user_uploaded': {'username': self._rhodecode_user.username,
67 'user_uploaded': {'username': self._rhodecode_user.username,
68 'user_id': self._rhodecode_user.user_id,
68 'user_id': self._rhodecode_user.user_id,
69 'ip': self._rhodecode_user.ip_addr}}
69 'ip': self._rhodecode_user.ip_addr}}
70 try:
70 try:
71 store_fid, metadata = self.storage.save_file(
71 store_uid, metadata = self.storage.save_file(
72 file_obj.file, filename, extra_metadata=metadata)
72 file_obj.file, filename, extra_metadata=metadata)
73 except FileNotAllowedException:
73 except FileNotAllowedException:
74 return {'store_fid': None,
74 return {'store_fid': None,
75 'access_path': None,
75 'access_path': None,
76 'error': 'File {} is not allowed.'.format(filename)}
76 'error': 'File {} is not allowed.'.format(filename)}
77
77
78 except FileOverSizeException:
78 except FileOverSizeException:
79 return {'store_fid': None,
79 return {'store_fid': None,
80 'access_path': None,
80 'access_path': None,
81 'error': 'File {} is exceeding allowed limit.'.format(filename)}
81 'error': 'File {} is exceeding allowed limit.'.format(filename)}
82
82
83 try:
83 try:
84 entry = FileStore.create(
84 entry = FileStore.create(
85 file_uid=store_fid, filename=metadata["filename"],
85 file_uid=store_uid, filename=metadata["filename"],
86 file_hash=metadata["sha256"], file_size=metadata["size"],
86 file_hash=metadata["sha256"], file_size=metadata["size"],
87 file_description='upload attachment',
87 file_description='upload attachment',
88 check_acl=False, user_id=self._rhodecode_user.user_id
88 check_acl=False, user_id=self._rhodecode_user.user_id
89 )
89 )
90 Session().add(entry)
90 Session().add(entry)
91 Session().commit()
91 Session().commit()
92 log.debug('Stored upload in DB as %s', entry)
92 log.debug('Stored upload in DB as %s', entry)
93 except Exception:
93 except Exception:
94 log.exception('Failed to store file %s', filename)
94 log.exception('Failed to store file %s', filename)
95 return {'store_fid': None,
95 return {'store_fid': None,
96 'access_path': None,
96 'access_path': None,
97 'error': 'File {} failed to store in DB.'.format(filename)}
97 'error': 'File {} failed to store in DB.'.format(filename)}
98
98
99 return {'store_fid': store_fid,
99 return {'store_fid': store_uid,
100 'access_path': h.route_path('download_file', fid=store_fid)}
100 'access_path': h.route_path('download_file', fid=store_uid)}
101
101
102 @view_config(route_name='download_file')
102 @view_config(route_name='download_file')
103 def download_file(self):
103 def download_file(self):
104 self.load_default_context()
104 self.load_default_context()
105 file_uid = self.request.matchdict['fid']
105 file_uid = self.request.matchdict['fid']
106 log.debug('Requesting FID:%s from store %s', file_uid, self.storage)
106 log.debug('Requesting FID:%s from store %s', file_uid, self.storage)
107
107
108 if not self.storage.exists(file_uid):
108 if not self.storage.exists(file_uid):
109 log.debug('File with FID:%s not found in the store', file_uid)
109 log.debug('File with FID:%s not found in the store', file_uid)
110 raise HTTPNotFound()
110 raise HTTPNotFound()
111
111
112 db_obj = FileStore().query().filter(FileStore.file_uid == file_uid).scalar()
112 db_obj = FileStore().query().filter(FileStore.file_uid == file_uid).scalar()
113 if not db_obj:
113 if not db_obj:
114 raise HTTPNotFound()
114 raise HTTPNotFound()
115
115
116 # private upload for user
116 # private upload for user
117 if db_obj.check_acl and db_obj.scope_user_id:
117 if db_obj.check_acl and db_obj.scope_user_id:
118 user = db_obj.user
118 user = db_obj.user
119 if self._rhodecode_db_user.user_id != user.user_id:
119 if self._rhodecode_db_user.user_id != user.user_id:
120 log.warning('Access to file store object forbidden')
120 log.warning('Access to file store object forbidden')
121 raise HTTPNotFound()
121 raise HTTPNotFound()
122
122
123 # scoped to repository permissions
123 # scoped to repository permissions
124 if db_obj.check_acl and db_obj.scope_repo_id:
124 if db_obj.check_acl and db_obj.scope_repo_id:
125 repo = db_obj.repo
125 repo = db_obj.repo
126 perm_set = ['repository.read', 'repository.write', 'repository.admin']
126 perm_set = ['repository.read', 'repository.write', 'repository.admin']
127 has_perm = HasRepoPermissionAny(*perm_set)(repo.repo_name, 'FileStore check')
127 has_perm = HasRepoPermissionAny(*perm_set)(repo.repo_name, 'FileStore check')
128 if not has_perm:
128 if not has_perm:
129 log.warning('Access to file store object forbidden')
129 log.warning('Access to file store object forbidden')
130 raise HTTPNotFound()
130 raise HTTPNotFound()
131
131
132 # scoped to repository group permissions
132 # scoped to repository group permissions
133 if db_obj.check_acl and db_obj.scope_repo_group_id:
133 if db_obj.check_acl and db_obj.scope_repo_group_id:
134 repo_group = db_obj.repo_group
134 repo_group = db_obj.repo_group
135 perm_set = ['group.read', 'group.write', 'group.admin']
135 perm_set = ['group.read', 'group.write', 'group.admin']
136 has_perm = HasRepoGroupPermissionAny(*perm_set)(repo_group.group_name, 'FileStore check')
136 has_perm = HasRepoGroupPermissionAny(*perm_set)(repo_group.group_name, 'FileStore check')
137 if not has_perm:
137 if not has_perm:
138 log.warning('Access to file store object forbidden')
138 log.warning('Access to file store object forbidden')
139 raise HTTPNotFound()
139 raise HTTPNotFound()
140
140
141 FileStore.bump_access_counter(file_uid)
141 FileStore.bump_access_counter(file_uid)
142
142
143 file_path = self.storage.store_path(file_uid)
143 file_path = self.storage.store_path(file_uid)
144 return FileResponse(file_path)
144 return FileResponse(file_path)
@@ -1,288 +1,291 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2017-2019 RhodeCode GmbH
3 # Copyright (C) 2017-2019 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import logging
21 import logging
22 import datetime
22 import datetime
23
23
24 from rhodecode.lib.jsonalchemy import JsonRaw
24 from rhodecode.lib.jsonalchemy import JsonRaw
25 from rhodecode.model import meta
25 from rhodecode.model import meta
26 from rhodecode.model.db import User, UserLog, Repository
26 from rhodecode.model.db import User, UserLog, Repository
27
27
28
28
29 log = logging.getLogger(__name__)
29 log = logging.getLogger(__name__)
30
30
31 # action as key, and expected action_data as value
31 # action as key, and expected action_data as value
32 ACTIONS_V1 = {
32 ACTIONS_V1 = {
33 'user.login.success': {'user_agent': ''},
33 'user.login.success': {'user_agent': ''},
34 'user.login.failure': {'user_agent': ''},
34 'user.login.failure': {'user_agent': ''},
35 'user.logout': {'user_agent': ''},
35 'user.logout': {'user_agent': ''},
36 'user.register': {},
36 'user.register': {},
37 'user.password.reset_request': {},
37 'user.password.reset_request': {},
38 'user.push': {'user_agent': '', 'commit_ids': []},
38 'user.push': {'user_agent': '', 'commit_ids': []},
39 'user.pull': {'user_agent': ''},
39 'user.pull': {'user_agent': ''},
40
40
41 'user.create': {'data': {}},
41 'user.create': {'data': {}},
42 'user.delete': {'old_data': {}},
42 'user.delete': {'old_data': {}},
43 'user.edit': {'old_data': {}},
43 'user.edit': {'old_data': {}},
44 'user.edit.permissions': {},
44 'user.edit.permissions': {},
45 'user.edit.ip.add': {'ip': {}, 'user': {}},
45 'user.edit.ip.add': {'ip': {}, 'user': {}},
46 'user.edit.ip.delete': {'ip': {}, 'user': {}},
46 'user.edit.ip.delete': {'ip': {}, 'user': {}},
47 'user.edit.token.add': {'token': {}, 'user': {}},
47 'user.edit.token.add': {'token': {}, 'user': {}},
48 'user.edit.token.delete': {'token': {}, 'user': {}},
48 'user.edit.token.delete': {'token': {}, 'user': {}},
49 'user.edit.email.add': {'email': ''},
49 'user.edit.email.add': {'email': ''},
50 'user.edit.email.delete': {'email': ''},
50 'user.edit.email.delete': {'email': ''},
51 'user.edit.ssh_key.add': {'token': {}, 'user': {}},
51 'user.edit.ssh_key.add': {'token': {}, 'user': {}},
52 'user.edit.ssh_key.delete': {'token': {}, 'user': {}},
52 'user.edit.ssh_key.delete': {'token': {}, 'user': {}},
53 'user.edit.password_reset.enabled': {},
53 'user.edit.password_reset.enabled': {},
54 'user.edit.password_reset.disabled': {},
54 'user.edit.password_reset.disabled': {},
55
55
56 'user_group.create': {'data': {}},
56 'user_group.create': {'data': {}},
57 'user_group.delete': {'old_data': {}},
57 'user_group.delete': {'old_data': {}},
58 'user_group.edit': {'old_data': {}},
58 'user_group.edit': {'old_data': {}},
59 'user_group.edit.permissions': {},
59 'user_group.edit.permissions': {},
60 'user_group.edit.member.add': {'user': {}},
60 'user_group.edit.member.add': {'user': {}},
61 'user_group.edit.member.delete': {'user': {}},
61 'user_group.edit.member.delete': {'user': {}},
62
62
63 'repo.create': {'data': {}},
63 'repo.create': {'data': {}},
64 'repo.fork': {'data': {}},
64 'repo.fork': {'data': {}},
65 'repo.edit': {'old_data': {}},
65 'repo.edit': {'old_data': {}},
66 'repo.edit.permissions': {},
66 'repo.edit.permissions': {},
67 'repo.edit.permissions.branch': {},
67 'repo.edit.permissions.branch': {},
68 'repo.archive': {'old_data': {}},
68 'repo.archive': {'old_data': {}},
69 'repo.delete': {'old_data': {}},
69 'repo.delete': {'old_data': {}},
70
70
71 'repo.archive.download': {'user_agent': '', 'archive_name': '',
71 'repo.archive.download': {'user_agent': '', 'archive_name': '',
72 'archive_spec': '', 'archive_cached': ''},
72 'archive_spec': '', 'archive_cached': ''},
73
73
74 'repo.permissions.branch_rule.create': {},
74 'repo.permissions.branch_rule.create': {},
75 'repo.permissions.branch_rule.edit': {},
75 'repo.permissions.branch_rule.edit': {},
76 'repo.permissions.branch_rule.delete': {},
76 'repo.permissions.branch_rule.delete': {},
77
77
78 'repo.pull_request.create': '',
78 'repo.pull_request.create': '',
79 'repo.pull_request.edit': '',
79 'repo.pull_request.edit': '',
80 'repo.pull_request.delete': '',
80 'repo.pull_request.delete': '',
81 'repo.pull_request.close': '',
81 'repo.pull_request.close': '',
82 'repo.pull_request.merge': '',
82 'repo.pull_request.merge': '',
83 'repo.pull_request.vote': '',
83 'repo.pull_request.vote': '',
84 'repo.pull_request.comment.create': '',
84 'repo.pull_request.comment.create': '',
85 'repo.pull_request.comment.delete': '',
85 'repo.pull_request.comment.delete': '',
86
86
87 'repo.pull_request.reviewer.add': '',
87 'repo.pull_request.reviewer.add': '',
88 'repo.pull_request.reviewer.delete': '',
88 'repo.pull_request.reviewer.delete': '',
89
89
90 'repo.commit.strip': {'commit_id': ''},
90 'repo.commit.strip': {'commit_id': ''},
91 'repo.commit.comment.create': {'data': {}},
91 'repo.commit.comment.create': {'data': {}},
92 'repo.commit.comment.delete': {'data': {}},
92 'repo.commit.comment.delete': {'data': {}},
93 'repo.commit.vote': '',
93 'repo.commit.vote': '',
94
94
95 'repo.artifact.add': '',
96 'repo.artifact.delete': '',
97
95 'repo_group.create': {'data': {}},
98 'repo_group.create': {'data': {}},
96 'repo_group.edit': {'old_data': {}},
99 'repo_group.edit': {'old_data': {}},
97 'repo_group.edit.permissions': {},
100 'repo_group.edit.permissions': {},
98 'repo_group.delete': {'old_data': {}},
101 'repo_group.delete': {'old_data': {}},
99 }
102 }
100
103
101 ACTIONS = ACTIONS_V1
104 ACTIONS = ACTIONS_V1
102
105
103 SOURCE_WEB = 'source_web'
106 SOURCE_WEB = 'source_web'
104 SOURCE_API = 'source_api'
107 SOURCE_API = 'source_api'
105
108
106
109
107 class UserWrap(object):
110 class UserWrap(object):
108 """
111 """
109 Fake object used to imitate AuthUser
112 Fake object used to imitate AuthUser
110 """
113 """
111
114
112 def __init__(self, user_id=None, username=None, ip_addr=None):
115 def __init__(self, user_id=None, username=None, ip_addr=None):
113 self.user_id = user_id
116 self.user_id = user_id
114 self.username = username
117 self.username = username
115 self.ip_addr = ip_addr
118 self.ip_addr = ip_addr
116
119
117
120
118 class RepoWrap(object):
121 class RepoWrap(object):
119 """
122 """
120 Fake object used to imitate RepoObject that audit logger requires
123 Fake object used to imitate RepoObject that audit logger requires
121 """
124 """
122
125
123 def __init__(self, repo_id=None, repo_name=None):
126 def __init__(self, repo_id=None, repo_name=None):
124 self.repo_id = repo_id
127 self.repo_id = repo_id
125 self.repo_name = repo_name
128 self.repo_name = repo_name
126
129
127
130
128 def _store_log(action_name, action_data, user_id, username, user_data,
131 def _store_log(action_name, action_data, user_id, username, user_data,
129 ip_address, repository_id, repository_name):
132 ip_address, repository_id, repository_name):
130 user_log = UserLog()
133 user_log = UserLog()
131 user_log.version = UserLog.VERSION_2
134 user_log.version = UserLog.VERSION_2
132
135
133 user_log.action = action_name
136 user_log.action = action_name
134 user_log.action_data = action_data or JsonRaw(u'{}')
137 user_log.action_data = action_data or JsonRaw(u'{}')
135
138
136 user_log.user_ip = ip_address
139 user_log.user_ip = ip_address
137
140
138 user_log.user_id = user_id
141 user_log.user_id = user_id
139 user_log.username = username
142 user_log.username = username
140 user_log.user_data = user_data or JsonRaw(u'{}')
143 user_log.user_data = user_data or JsonRaw(u'{}')
141
144
142 user_log.repository_id = repository_id
145 user_log.repository_id = repository_id
143 user_log.repository_name = repository_name
146 user_log.repository_name = repository_name
144
147
145 user_log.action_date = datetime.datetime.now()
148 user_log.action_date = datetime.datetime.now()
146
149
147 return user_log
150 return user_log
148
151
149
152
150 def store_web(*args, **kwargs):
153 def store_web(*args, **kwargs):
151 if 'action_data' not in kwargs:
154 if 'action_data' not in kwargs:
152 kwargs['action_data'] = {}
155 kwargs['action_data'] = {}
153 kwargs['action_data'].update({
156 kwargs['action_data'].update({
154 'source': SOURCE_WEB
157 'source': SOURCE_WEB
155 })
158 })
156 return store(*args, **kwargs)
159 return store(*args, **kwargs)
157
160
158
161
159 def store_api(*args, **kwargs):
162 def store_api(*args, **kwargs):
160 if 'action_data' not in kwargs:
163 if 'action_data' not in kwargs:
161 kwargs['action_data'] = {}
164 kwargs['action_data'] = {}
162 kwargs['action_data'].update({
165 kwargs['action_data'].update({
163 'source': SOURCE_API
166 'source': SOURCE_API
164 })
167 })
165 return store(*args, **kwargs)
168 return store(*args, **kwargs)
166
169
167
170
168 def store(action, user, action_data=None, user_data=None, ip_addr=None,
171 def store(action, user, action_data=None, user_data=None, ip_addr=None,
169 repo=None, sa_session=None, commit=False):
172 repo=None, sa_session=None, commit=False):
170 """
173 """
171 Audit logger for various actions made by users, typically this
174 Audit logger for various actions made by users, typically this
172 results in a call such::
175 results in a call such::
173
176
174 from rhodecode.lib import audit_logger
177 from rhodecode.lib import audit_logger
175
178
176 audit_logger.store(
179 audit_logger.store(
177 'repo.edit', user=self._rhodecode_user)
180 'repo.edit', user=self._rhodecode_user)
178 audit_logger.store(
181 audit_logger.store(
179 'repo.delete', action_data={'data': repo_data},
182 'repo.delete', action_data={'data': repo_data},
180 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'))
183 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'))
181
184
182 # repo action
185 # repo action
183 audit_logger.store(
186 audit_logger.store(
184 'repo.delete',
187 'repo.delete',
185 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'),
188 user=audit_logger.UserWrap(username='itried-login', ip_addr='8.8.8.8'),
186 repo=audit_logger.RepoWrap(repo_name='some-repo'))
189 repo=audit_logger.RepoWrap(repo_name='some-repo'))
187
190
188 # repo action, when we know and have the repository object already
191 # repo action, when we know and have the repository object already
189 audit_logger.store(
192 audit_logger.store(
190 'repo.delete', action_data={'source': audit_logger.SOURCE_WEB, },
193 'repo.delete', action_data={'source': audit_logger.SOURCE_WEB, },
191 user=self._rhodecode_user,
194 user=self._rhodecode_user,
192 repo=repo_object)
195 repo=repo_object)
193
196
194 # alternative wrapper to the above
197 # alternative wrapper to the above
195 audit_logger.store_web(
198 audit_logger.store_web(
196 'repo.delete', action_data={},
199 'repo.delete', action_data={},
197 user=self._rhodecode_user,
200 user=self._rhodecode_user,
198 repo=repo_object)
201 repo=repo_object)
199
202
200 # without an user ?
203 # without an user ?
201 audit_logger.store(
204 audit_logger.store(
202 'user.login.failure',
205 'user.login.failure',
203 user=audit_logger.UserWrap(
206 user=audit_logger.UserWrap(
204 username=self.request.params.get('username'),
207 username=self.request.params.get('username'),
205 ip_addr=self.request.remote_addr))
208 ip_addr=self.request.remote_addr))
206
209
207 """
210 """
208 from rhodecode.lib.utils2 import safe_unicode
211 from rhodecode.lib.utils2 import safe_unicode
209 from rhodecode.lib.auth import AuthUser
212 from rhodecode.lib.auth import AuthUser
210
213
211 action_spec = ACTIONS.get(action, None)
214 action_spec = ACTIONS.get(action, None)
212 if action_spec is None:
215 if action_spec is None:
213 raise ValueError('Action `{}` is not supported'.format(action))
216 raise ValueError('Action `{}` is not supported'.format(action))
214
217
215 if not sa_session:
218 if not sa_session:
216 sa_session = meta.Session()
219 sa_session = meta.Session()
217
220
218 try:
221 try:
219 username = getattr(user, 'username', None)
222 username = getattr(user, 'username', None)
220 if not username:
223 if not username:
221 pass
224 pass
222
225
223 user_id = getattr(user, 'user_id', None)
226 user_id = getattr(user, 'user_id', None)
224 if not user_id:
227 if not user_id:
225 # maybe we have username ? Try to figure user_id from username
228 # maybe we have username ? Try to figure user_id from username
226 if username:
229 if username:
227 user_id = getattr(
230 user_id = getattr(
228 User.get_by_username(username), 'user_id', None)
231 User.get_by_username(username), 'user_id', None)
229
232
230 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
233 ip_addr = ip_addr or getattr(user, 'ip_addr', None)
231 if not ip_addr:
234 if not ip_addr:
232 pass
235 pass
233
236
234 if not user_data:
237 if not user_data:
235 # try to get this from the auth user
238 # try to get this from the auth user
236 if isinstance(user, AuthUser):
239 if isinstance(user, AuthUser):
237 user_data = {
240 user_data = {
238 'username': user.username,
241 'username': user.username,
239 'email': user.email,
242 'email': user.email,
240 }
243 }
241
244
242 repository_name = getattr(repo, 'repo_name', None)
245 repository_name = getattr(repo, 'repo_name', None)
243 repository_id = getattr(repo, 'repo_id', None)
246 repository_id = getattr(repo, 'repo_id', None)
244 if not repository_id:
247 if not repository_id:
245 # maybe we have repo_name ? Try to figure repo_id from repo_name
248 # maybe we have repo_name ? Try to figure repo_id from repo_name
246 if repository_name:
249 if repository_name:
247 repository_id = getattr(
250 repository_id = getattr(
248 Repository.get_by_repo_name(repository_name), 'repo_id', None)
251 Repository.get_by_repo_name(repository_name), 'repo_id', None)
249
252
250 action_name = safe_unicode(action)
253 action_name = safe_unicode(action)
251 ip_address = safe_unicode(ip_addr)
254 ip_address = safe_unicode(ip_addr)
252
255
253 with sa_session.no_autoflush:
256 with sa_session.no_autoflush:
254 update_user_last_activity(sa_session, user_id)
257 update_user_last_activity(sa_session, user_id)
255
258
256 user_log = _store_log(
259 user_log = _store_log(
257 action_name=action_name,
260 action_name=action_name,
258 action_data=action_data or {},
261 action_data=action_data or {},
259 user_id=user_id,
262 user_id=user_id,
260 username=username,
263 username=username,
261 user_data=user_data or {},
264 user_data=user_data or {},
262 ip_address=ip_address,
265 ip_address=ip_address,
263 repository_id=repository_id,
266 repository_id=repository_id,
264 repository_name=repository_name
267 repository_name=repository_name
265 )
268 )
266
269
267 sa_session.add(user_log)
270 sa_session.add(user_log)
268
271
269 if commit:
272 if commit:
270 sa_session.commit()
273 sa_session.commit()
271
274
272 entry_id = user_log.entry_id or ''
275 entry_id = user_log.entry_id or ''
273 log.info('AUDIT[%s]: Logging action: `%s` by user:id:%s[%s] ip:%s',
276 log.info('AUDIT[%s]: Logging action: `%s` by user:id:%s[%s] ip:%s',
274 entry_id, action_name, user_id, username, ip_address)
277 entry_id, action_name, user_id, username, ip_address)
275
278
276 except Exception:
279 except Exception:
277 log.exception('AUDIT: failed to store audit log')
280 log.exception('AUDIT: failed to store audit log')
278
281
279
282
280 def update_user_last_activity(sa_session, user_id):
283 def update_user_last_activity(sa_session, user_id):
281 _last_activity = datetime.datetime.now()
284 _last_activity = datetime.datetime.now()
282 try:
285 try:
283 sa_session.query(User).filter(User.user_id == user_id).update(
286 sa_session.query(User).filter(User.user_id == user_id).update(
284 {"last_activity": _last_activity})
287 {"last_activity": _last_activity})
285 log.debug(
288 log.debug(
286 'updated user `%s` last activity to:%s', user_id, _last_activity)
289 'updated user `%s` last activity to:%s', user_id, _last_activity)
287 except Exception:
290 except Exception:
288 log.exception("Failed last activity update")
291 log.exception("Failed last activity update")
General Comments 0
You need to be logged in to leave comments. Login now