Re-working the way audit logs work.
* Adding more checks.
This commit is contained in:
parent
744e204817
commit
6eca2eb147
|
@ -3,3 +3,8 @@
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- repo: git://github.com/pre-commit/mirrors-jshint
|
||||||
|
sha: e72140112bdd29b18b0c8257956c896c4c3cebcb
|
||||||
|
hooks:
|
||||||
|
- id: jshint
|
||||||
|
|
|
@ -10,8 +10,6 @@ addons:
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- python: "2.7"
|
|
||||||
env: TOXENV=py27
|
|
||||||
- python: "3.5"
|
- python: "3.5"
|
||||||
env: TOXENV=py35
|
env: TOXENV=py35
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ from lemur.plugins.views import mod as plugins_bp
|
||||||
from lemur.notifications.views import mod as notifications_bp
|
from lemur.notifications.views import mod as notifications_bp
|
||||||
from lemur.sources.views import mod as sources_bp
|
from lemur.sources.views import mod as sources_bp
|
||||||
from lemur.endpoints.views import mod as endpoints_bp
|
from lemur.endpoints.views import mod as endpoints_bp
|
||||||
|
from lemur.logs.views import mod as logs_bp
|
||||||
|
|
||||||
from lemur.__about__ import (
|
from lemur.__about__ import (
|
||||||
__author__, __copyright__, __email__, __license__, __summary__, __title__,
|
__author__, __copyright__, __email__, __license__, __summary__, __title__,
|
||||||
|
@ -49,7 +50,8 @@ LEMUR_BLUEPRINTS = (
|
||||||
plugins_bp,
|
plugins_bp,
|
||||||
notifications_bp,
|
notifications_bp,
|
||||||
sources_bp,
|
sources_bp,
|
||||||
endpoints_bp
|
endpoints_bp,
|
||||||
|
logs_bp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -79,7 +79,7 @@ class Certificate(db.Model):
|
||||||
secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa
|
secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa
|
||||||
backref='replaced')
|
backref='replaced')
|
||||||
|
|
||||||
views = relationship("View", backref="certificate")
|
logs = relationship("Log", backref="certificate")
|
||||||
endpoints = relationship("Endpoint", backref='certificate')
|
endpoints = relationship("Endpoint", backref='certificate')
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
|
|
@ -10,6 +10,11 @@ import arrow
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
from lemur import database
|
from lemur import database
|
||||||
from lemur.extensions import metrics
|
from lemur.extensions import metrics
|
||||||
from lemur.plugins.base import plugins
|
from lemur.plugins.base import plugins
|
||||||
|
@ -19,16 +24,10 @@ from lemur.destinations.models import Destination
|
||||||
from lemur.notifications.models import Notification
|
from lemur.notifications.models import Notification
|
||||||
from lemur.authorities.models import Authority
|
from lemur.authorities.models import Authority
|
||||||
from lemur.domains.models import Domain
|
from lemur.domains.models import Domain
|
||||||
from lemur.users.models import View
|
|
||||||
|
|
||||||
from lemur.roles.models import Role
|
from lemur.roles.models import Role
|
||||||
from lemur.roles import service as role_service
|
from lemur.roles import service as role_service
|
||||||
|
|
||||||
from cryptography import x509
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
||||||
|
|
||||||
|
|
||||||
def get(cert_id):
|
def get(cert_id):
|
||||||
"""
|
"""
|
||||||
|
@ -130,19 +129,6 @@ def update(cert_id, owner, description, notify, destinations, notifications, rep
|
||||||
return database.update(cert)
|
return database.update(cert)
|
||||||
|
|
||||||
|
|
||||||
def log_private_key_view(certificate, user):
|
|
||||||
"""
|
|
||||||
Creates a record each time a certificates private key is viewed.
|
|
||||||
|
|
||||||
:param certificate:
|
|
||||||
:param user:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
view = View(user_id=user.id, certificate_id=certificate.id)
|
|
||||||
database.add(view)
|
|
||||||
database.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def create_certificate_roles(**kwargs):
|
def create_certificate_roles(**kwargs):
|
||||||
# create an role for the owner and assign it
|
# create an role for the owner and assign it
|
||||||
owner_role = role_service.get_by_name(kwargs['owner'])
|
owner_role = role_service.get_by_name(kwargs['owner'])
|
||||||
|
|
|
@ -22,6 +22,7 @@ from lemur.certificates.schemas import certificate_input_schema, certificate_out
|
||||||
certificate_upload_input_schema, certificates_output_schema, certificate_export_input_schema, certificate_edit_input_schema
|
certificate_upload_input_schema, certificates_output_schema, certificate_export_input_schema, certificate_edit_input_schema
|
||||||
|
|
||||||
from lemur.roles import service as role_service
|
from lemur.roles import service as role_service
|
||||||
|
from lemur.logs import service as log_service
|
||||||
|
|
||||||
|
|
||||||
mod = Blueprint('certificates', __name__)
|
mod = Blueprint('certificates', __name__)
|
||||||
|
@ -444,7 +445,7 @@ class CertificatePrivateKey(AuthenticatedResource):
|
||||||
if not permission.can():
|
if not permission.can():
|
||||||
return dict(message='You are not authorized to view this key'), 403
|
return dict(message='You are not authorized to view this key'), 403
|
||||||
|
|
||||||
service.log_private_key_view(cert, g.current_user)
|
log_service.create(g.current_user, 'key_view', certificate=cert)
|
||||||
response = make_response(jsonify(key=cert.private_key), 200)
|
response = make_response(jsonify(key=cert.private_key), 200)
|
||||||
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store'
|
||||||
response.headers['pragma'] = 'no-cache'
|
response.headers['pragma'] = 'no-cache'
|
||||||
|
@ -931,7 +932,7 @@ class CertificateExport(AuthenticatedResource):
|
||||||
|
|
||||||
options = data['plugin']['plugin_options']
|
options = data['plugin']['plugin_options']
|
||||||
|
|
||||||
service.log_private_key_view(cert, g.current_user)
|
log_service.create(g.current_user, 'key_view', certificate=cert)
|
||||||
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
|
extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options)
|
||||||
|
|
||||||
# we take a hit in message size when b64 encoding
|
# we take a hit in message size when b64 encoding
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.logs.models
|
||||||
|
:platform: unix
|
||||||
|
:synopsis: This module contains all of the models related private key audit log.
|
||||||
|
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
|
||||||
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, ForeignKey, PassiveDefault, func, Enum
|
||||||
|
|
||||||
|
from sqlalchemy_utils.types.arrow import ArrowType
|
||||||
|
|
||||||
|
from lemur.database import db
|
||||||
|
|
||||||
|
|
||||||
|
class Log(db.Model):
|
||||||
|
__tablename__ = 'log'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
certificate_id = Column(Integer, ForeignKey('certificates.id'))
|
||||||
|
log_type = Column(Enum('key_view', name='log_type'), nullable=False)
|
||||||
|
logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.logs.schemas
|
||||||
|
:platform: unix
|
||||||
|
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
|
"""
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
|
from lemur.common.schema import LemurOutputSchema
|
||||||
|
from lemur.certificates.schemas import CertificateNestedOutputSchema
|
||||||
|
from lemur.users.schemas import UserNestedOutputSchema
|
||||||
|
|
||||||
|
|
||||||
|
class LogOutputSchema(LemurOutputSchema):
|
||||||
|
id = fields.Integer()
|
||||||
|
certificate = fields.Nested(CertificateNestedOutputSchema)
|
||||||
|
user = fields.Nested(UserNestedOutputSchema)
|
||||||
|
logged_at = fields.DateTime()
|
||||||
|
log_type = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
logs_output_schema = LogOutputSchema(many=True)
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.logs.service
|
||||||
|
:platform: Unix
|
||||||
|
:synopsis: This module contains all of the services level functions used to
|
||||||
|
administer logs in Lemur
|
||||||
|
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
|
"""
|
||||||
|
from lemur import database
|
||||||
|
from lemur.logs.models import Log
|
||||||
|
|
||||||
|
|
||||||
|
def create(user, type, certificate=None):
|
||||||
|
"""
|
||||||
|
Creates logs a given action.
|
||||||
|
|
||||||
|
:param user:
|
||||||
|
:param type:
|
||||||
|
:param certificate:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
view = Log(user_id=user.id, log_type=type, certificate_id=certificate.id)
|
||||||
|
database.add(view)
|
||||||
|
database.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all():
|
||||||
|
"""
|
||||||
|
Retrieve all logs from the database.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
query = database.session_query(Log)
|
||||||
|
return database.find_all(query, Log, {}).all()
|
||||||
|
|
||||||
|
|
||||||
|
def render(args):
|
||||||
|
"""
|
||||||
|
Helper that paginates and filters data when requested
|
||||||
|
through the REST Api
|
||||||
|
|
||||||
|
:param args:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
query = database.session_query(Log)
|
||||||
|
|
||||||
|
filt = args.pop('filter')
|
||||||
|
|
||||||
|
if filt:
|
||||||
|
terms = filt.split(';')
|
||||||
|
query = database.filter(query, Log, terms)
|
||||||
|
|
||||||
|
return database.sort_and_page(query, Log, args)
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.log.views
|
||||||
|
:platform: Unix
|
||||||
|
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
|
from flask.ext.restful import reqparse, Api
|
||||||
|
|
||||||
|
from lemur.common.schema import validate_schema
|
||||||
|
from lemur.common.utils import paginated_parser
|
||||||
|
|
||||||
|
from lemur.auth.service import AuthenticatedResource
|
||||||
|
from lemur.logs.schemas import logs_output_schema
|
||||||
|
|
||||||
|
from lemur.logs import service
|
||||||
|
|
||||||
|
|
||||||
|
mod = Blueprint('logs', __name__)
|
||||||
|
api = Api(mod)
|
||||||
|
|
||||||
|
|
||||||
|
class LogsList(AuthenticatedResource):
|
||||||
|
""" Defines the 'logs' endpoint """
|
||||||
|
def __init__(self):
|
||||||
|
self.reqparse = reqparse.RequestParser()
|
||||||
|
super(LogsList, self).__init__()
|
||||||
|
|
||||||
|
@validate_schema(None, logs_output_schema)
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
.. http:get:: /logs
|
||||||
|
|
||||||
|
The current log list
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /logs HTTP/1.1
|
||||||
|
Host: example.com
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: text/javascript
|
||||||
|
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
]
|
||||||
|
"total": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
:query sortBy: field to sort on
|
||||||
|
:query sortDir: acs or desc
|
||||||
|
:query page: int default is 1
|
||||||
|
:query filter: key value pair format is k;v
|
||||||
|
:query count: count number default is 10
|
||||||
|
:reqheader Authorization: OAuth token to authenticate
|
||||||
|
:statuscode 200: no error
|
||||||
|
"""
|
||||||
|
parser = paginated_parser.copy()
|
||||||
|
parser.add_argument('owner', type=str, location='args')
|
||||||
|
parser.add_argument('id', type=str, location='args')
|
||||||
|
args = parser.parse_args()
|
||||||
|
return service.render(args)
|
||||||
|
|
||||||
|
|
||||||
|
api.add_resource(LogsList, '/logs', endpoint='logs')
|
|
@ -43,6 +43,7 @@ from lemur.destinations.models import Destination # noqa
|
||||||
from lemur.domains.models import Domain # noqa
|
from lemur.domains.models import Domain # noqa
|
||||||
from lemur.notifications.models import Notification # noqa
|
from lemur.notifications.models import Notification # noqa
|
||||||
from lemur.sources.models import Source # noqa
|
from lemur.sources.models import Source # noqa
|
||||||
|
from lemur.logs.models import Log # noqa
|
||||||
|
|
||||||
|
|
||||||
manager = Manager(create_app)
|
manager = Manager(create_app)
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
angular.module('lemur')
|
||||||
|
.service('LogApi', function (LemurRestangular) {
|
||||||
|
return LemurRestangular.all('domains');
|
||||||
|
})
|
||||||
|
.service('LogService', function () {
|
||||||
|
var LogService = this;
|
||||||
|
return LogService;
|
||||||
|
});
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
angular.module('lemur')
|
||||||
|
|
||||||
|
.config(function config($stateProvider) {
|
||||||
|
$stateProvider.state('logs', {
|
||||||
|
url: '/logs',
|
||||||
|
templateUrl: '/angular/logs/view/view.tpl.html',
|
||||||
|
controller: 'DomainsViewController'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.controller('DomainsViewController', function ($scope, $uibModal, DomainApi, DomainService, ngTableParams) {
|
||||||
|
$scope.filter = {};
|
||||||
|
$scope.logsTable = new ngTableParams({
|
||||||
|
page: 1, // show first page
|
||||||
|
count: 10, // count per page
|
||||||
|
sorting: {
|
||||||
|
id: 'desc' // initial sorting
|
||||||
|
},
|
||||||
|
filter: $scope.filter
|
||||||
|
}, {
|
||||||
|
total: 0, // length of data
|
||||||
|
getData: function ($defer, params) {
|
||||||
|
DomainApi.getList(params.url()).then(function (data) {
|
||||||
|
params.total(data.total);
|
||||||
|
$defer.resolve(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,32 @@
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h2 class="featurette-heading">Logs
|
||||||
|
<span class="text-muted"><small>I see what you did there</small></span></h2>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button ng-model="showFilter" class="btn btn-default" uib-btn-checkbox
|
||||||
|
btn-checkbox-true="1"
|
||||||
|
btn-checkbox-false="0">Filter</button>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table ng-table="domainsTable" class="table table-striped" show-filter="showFilter" template-pagination="angular/pager.html">
|
||||||
|
<tbody>
|
||||||
|
<tr ng-repeat="log in $data track by $index">
|
||||||
|
<td data-title="'Logged At'" sortable="'logged_at'" filter="{ 'logged_at': 'text' }">
|
||||||
|
{{ log.logged_at }}
|
||||||
|
</td>
|
||||||
|
<td data-title="'User'" sortable="'user'" filter="{ 'user.email': 'text' }">
|
||||||
|
{{ log.user.email }}
|
||||||
|
</td>
|
||||||
|
<td data-title="'Type'" sortable="'type'" filter="{ 'type': 'text' }">
|
||||||
|
{{ log.type }}
|
||||||
|
</td>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -55,13 +55,14 @@
|
||||||
<li><a ui-sref="notifications">Notifications</a></li>
|
<li><a ui-sref="notifications">Notifications</a></li>
|
||||||
<li></li>
|
<li></li>
|
||||||
<li class="dropdown" uib-dropdown on-toggle="toggled(open)">
|
<li class="dropdown" uib-dropdown on-toggle="toggled(open)">
|
||||||
<a href class="dropdown-toggle" uib-dropdown-toggle>Settings <span class="caret"></span></a>
|
<a href class="dropdown-toggle" uib-dropdown-toggle>Admin <span class="caret"></span></a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a ui-sref="destinations">Destinations</a></li>
|
<li><a ui-sref="destinations">Destinations</a></li>
|
||||||
<li><a ui-sref="sources">Sources</a></li>
|
<li><a ui-sref="sources">Sources</a></li>
|
||||||
<li><a ui-sref="roles">Roles</a></li>
|
<li><a ui-sref="roles">Roles</a></li>
|
||||||
<li><a ui-sref="users">Users</a></li>
|
<li><a ui-sref="users">Users</a></li>
|
||||||
<li><a ui-sref="domains">Domains</a></li>
|
<li><a ui-sref="domains">Domains</a></li>
|
||||||
|
<li><a ui-sref="log">Log</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -423,12 +423,6 @@ def test_upload_private_key_str(user):
|
||||||
assert cert
|
assert cert
|
||||||
|
|
||||||
|
|
||||||
def test_private_key_audit(client, certificate):
|
|
||||||
assert len(certificate.views) == 0
|
|
||||||
client.get(api.url_for(CertificatePrivateKey, certificate_id=certificate.id), headers=VALID_ADMIN_HEADER_TOKEN)
|
|
||||||
assert len(certificate.views) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("token,status", [
|
@pytest.mark.parametrize("token,status", [
|
||||||
(VALID_USER_HEADER_TOKEN, 200),
|
(VALID_USER_HEADER_TOKEN, 200),
|
||||||
(VALID_ADMIN_HEADER_TOKEN, 200),
|
(VALID_ADMIN_HEADER_TOKEN, 200),
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import pytest
|
||||||
|
from lemur.tests.vectors import VALID_ADMIN_HEADER_TOKEN, VALID_USER_HEADER_TOKEN
|
||||||
|
|
||||||
|
from lemur.logs.views import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_key_audit(client, certificate):
|
||||||
|
from lemur.certificates.views import CertificatePrivateKey, api
|
||||||
|
assert len(certificate.logs) == 0
|
||||||
|
client.get(api.url_for(CertificatePrivateKey, certificate_id=certificate.id), headers=VALID_ADMIN_HEADER_TOKEN)
|
||||||
|
assert len(certificate.logs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("token,status", [
|
||||||
|
(VALID_USER_HEADER_TOKEN, 200),
|
||||||
|
(VALID_ADMIN_HEADER_TOKEN, 200),
|
||||||
|
('', 401)
|
||||||
|
])
|
||||||
|
def test_get_logs(client, token, status):
|
||||||
|
assert client.get(api.url_for(LogsList), headers=token).status_code == status
|
|
@ -9,7 +9,7 @@
|
||||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
"""
|
"""
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy import Integer, ForeignKey, String, PassiveDefault, func, Column, Boolean
|
from sqlalchemy import Integer, String, Column, Boolean
|
||||||
from sqlalchemy.event import listen
|
from sqlalchemy.event import listen
|
||||||
|
|
||||||
from sqlalchemy_utils.types.arrow import ArrowType
|
from sqlalchemy_utils.types.arrow import ArrowType
|
||||||
|
@ -81,12 +81,4 @@ class User(db.Model):
|
||||||
return "User(username={username})".format(username=self.username)
|
return "User(username={username})".format(username=self.username)
|
||||||
|
|
||||||
|
|
||||||
class View(db.Model):
|
|
||||||
__tablename__ = 'views'
|
|
||||||
id = Column(Integer, primary_key=True)
|
|
||||||
certificate_id = Column(Integer, ForeignKey('certificates.id'))
|
|
||||||
viewed_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False)
|
|
||||||
user_id = Column(Integer, ForeignKey('users.id'))
|
|
||||||
|
|
||||||
|
|
||||||
listen(User, 'before_insert', hash_password)
|
listen(User, 'before_insert', hash_password)
|
||||||
|
|
Loading…
Reference in New Issue