Skip to content

Commit

Permalink
Perms (pallets-eco#675)
Browse files Browse the repository at this point in the history
* Fix backwards compat issues with permissions.

Now - if an app uses fsqla_vX it just works.
Added description in CHANGES on how apps that don't use fsqla_vX need to be modified.

Added some tests to verify working with older DBs.

The fsqlalchemy example was crazy - trying to use mocks made everything very complex. Converted to a simpler in-memory DB style testing.

closes pallets-eco#673
  • Loading branch information
jwag956 authored Sep 22, 2022
1 parent 01d2a33 commit aba7b66
Show file tree
Hide file tree
Showing 17 changed files with 367 additions and 217 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ repos:
- id: check-merge-conflict
- id: fix-byte-order-marker
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.3
rev: v2.38.0
hooks:
- id: pyupgrade
args: [--py37-plus]
Expand Down
35 changes: 29 additions & 6 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ Flask-Security Changelog

Here you can see the full list of changes between each Flask-Security release.

Version 5.0.2
-------------

Released September x, 2022

Fixes
+++++
- (:issue:`673`) Role permissions backwards compatibility bug. For SQL based datastores
that use Flask-Security's models.fsqla_vx - there should be NO issues. If you declare
your own models - please see the 5.0.0 releases notes for required change.

Version 5.0.1
-------------

Expand Down Expand Up @@ -47,10 +58,11 @@ Deprecations
- (:pr:`657`) The ability to pass in a json_encoder_cls as part of initialization has been removed
since Flask 2.2 has deprecated and replaced that functionality.
- (:pr:`655`) Flask has deprecated @before_first_request. This was used mostly in examples/quickstart.
These have been changed to use app.app_context() prior to running the app. FS itself used it in
These have been changed to use app.app_context() prior to running the app. Flask-Security itself used it in
2 places - to populate `_` in jinja globals if Babel wasn't initialized and to perform
various configuration sanity checks w.r.t. WTF CSRF. All FS templates have been converted
to use `_fsdomain` rather than ``_`` so FS no longer will populate ``_``. The configuration checks
various configuration sanity checks w.r.t. WTF CSRF. All Flask-Security templates have been converted
to use `_fsdomain` rather than ``_`` so Flask-Security no longer sets ``_`` into jinja2 globals.
The configuration checks
have been moved to the end of Security::init_app() - so it is now imperative that `FlaskWTF::CSRFProtect()`
be called PRIOR to initializing Flask-Security.
- encrypt_password method has been removed. It has been deprecated since 2.0.2
Expand Down Expand Up @@ -134,9 +146,20 @@ Other:
The key `field_errors` will contain the dict as specified by WTForms. Please note that starting with WTForms 3.0
form-level errors are supported and show up in the dict with the field name/key of "none". There are no changes to non-error
related JSON responses.
- Permissions - The Role Model now stores permissions as a list, and requires that the underlying DB ORM map that to a supported
DB type. For SQLAlchemy, this is mapped to a comma separated string (as before). For Mongo, a ListField can be directly used. For
SQLAlchemy DBs the Column type (UnicodeText) didn't change so no data migration should be required.
- Permissions **THIS IS A BREAKING CHANGE**. The Role Model now stores permissions as a list, and requires that the underlying DB ORM map that to a supported
DB type. For SQLAlchemy, this is mapped to a comma separated string (as before). For
SQLAlchemy DBs the underlying Column type (UnicodeText) didn't change so no data migration should be required.
However, the ORM Column type did change and requires the following change to your model::

from flask_security import AsaList
from sqlalchemy.ext.mutable import MutableList
class Role(Base, RoleMixin):
...
permissions = Column(MutableList.as_mutable(AsaList()), nullable=True)
...

If your application makes use of Flask-Security's models.fsqla_vX classes - no changes are required.
For Mongo, a ListField can be directly used.
- CSRF - As mentioned above, it is now required that `FlaskWTF::CSRFProtect()`, if used, must be called PRIOR to initializing Flask-Security.
- json_encoder_cls - As mentioned above - Flask-Security initialization on longer accepts overriding the json_encoder class. If this is required,
update to Flask >=2.2 and implement Flask's JSONProvider interface.
Expand Down
2 changes: 1 addition & 1 deletion docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Permissions
If you want to protect endpoints with permissions, and assign permissions to roles
that are then assigned to users, the ``Role`` model requires:

* ``permissions`` (list of string/UnicodeText, nullable)
* ``permissions`` (list of UnicodeText, nullable)

WebAuthn
^^^^^^^^
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ in 2 ways:
* Complete app with in-memory/temporary DB (with little or no mocking).

Look in the `Flask-Security repo`_ *examples* directory for actual code that implements the
first approach.
second approach which is much simpler and with an in-memory DB fairly fast.

You also might want to set the following configurations in your conftest.py:

Expand Down
271 changes: 139 additions & 132 deletions examples/fsqlalchemy1/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,8 @@
)
from flask_security.models import fsqla_v2 as fsqla

# Create app
app = Flask(__name__)
app.config["DEBUG"] = True
# generated using: secrets.token_urlsafe()
app.config["SECRET_KEY"] = "pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw"
app.config["SECURITY_PASSWORD_HASH"] = "argon2"
# argon2 uses double hashing by default - so provide key.
# For python3: secrets.SystemRandom().getrandbits(128)
app.config["SECURITY_PASSWORD_SALT"] = "146585145368132386173505678016728509634"

# Take password complexity seriously
app.config["SECURITY_PASSWORD_COMPLEXITY_CHECKER"] = "zxcvbn"

# Allow registration of new users without confirmation
app.config["SECURITY_REGISTERABLE"] = True

app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
"SQLALCHEMY_DATABASE_URI", "sqlite://"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

# As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the
# underlying engine. This option makes sure that DB connections from the pool
# are still valid. Important for entire application since many DBaaS options
# automatically close idle connections.
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}

# Create database connection object
db = SQLAlchemy(app)
db = SQLAlchemy()

# Define models - for this example - we change the default table names
fsqla.FsModels.set_db_info(db, user_table_name="myuser", role_table_name="myrole")
Expand All @@ -81,122 +54,156 @@ class Blog(db.Model):
text = Column(UnicodeText)


# Setup Flask-Security
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
app.security = Security(app, user_datastore)

# Setup Babel - not strictly necessary but since our virtualenv has Flask-Babel
# we need to initialize it
Babel(app)
# Create app
def create_app():
app = Flask(__name__)
app.config["DEBUG"] = True
# generated using: secrets.token_urlsafe()
app.config["SECRET_KEY"] = "pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw"
app.config["SECURITY_PASSWORD_HASH"] = "argon2"
# argon2 uses double hashing by default - so provide key.
# For python3: secrets.SystemRandom().getrandbits(128)
app.config["SECURITY_PASSWORD_SALT"] = "146585145368132386173505678016728509634"

# Take password complexity seriously
app.config["SECURITY_PASSWORD_COMPLEXITY_CHECKER"] = "zxcvbn"

# Allow registration of new users without confirmation
app.config["SECURITY_REGISTERABLE"] = True

app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
"SQLALCHEMY_DATABASE_URI", "sqlite://"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

# As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the
# underlying engine. This option makes sure that DB connections from the pool
# are still valid. Important for entire application since many DBaaS options
# automatically close idle connections.
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}

# Setup Flask-Security
db.init_app(app)
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
app.security = Security(app, user_datastore)

# Setup Babel - not strictly necessary but since our virtualenv has Flask-Babel
# we need to initialize it
Babel(app)

# Set this so unit tests can mock out.
app.blog_cls = Blog

# Views
# Note that we always add @auth_required so that if a client isn't logged in
# we will get a proper '401' and redirected to login page.
@app.route("/")
@auth_required()
def home():
return render_template_string("Hello {{ current_user.email }}")

@app.route("/admin")
@auth_required()
@permissions_accepted("admin-read", "admin-write")
def admin():
return render_template_string(
"Hello on admin page. Current user {} password is {}".format(
current_user.email, current_user.password
)
)

# Set this so unit tests can mock out.
app.blog_cls = Blog
@app.route("/ops")
@auth_required()
@roles_accepted("monitor")
def monitor():
# Example of using just a role. Note that 'admin' can't access this
# since it doesn't have the 'monitor' role - even though it has
# all the permissions that the 'monitor' role has.
return render_template_string("Hello OPS")

@app.route("/blog/<bid>", methods=["GET", "POST"])
@auth_required()
@permissions_required("user-write")
def update_blog(bid):
# Yes caller has write permission - but do they OWN this blog?
blog = current_app.blog_cls.query.get(bid)
if not blog:
abort(404)
if current_user != blog.user:
abort(403)
return render_template_string("Yes, {{ current_user.email }} can update blog")

@app.route("/myblogs", methods=["GET"])
@auth_required()
@permissions_accepted("user-read")
def list_my_blogs():
blogs = current_user.blogs
blist = ""
cnt = 0
for blog in blogs:
blist += f" {blog.title}"
cnt += 1
if not blogs:
abort(404)
return render_template_string(f"Found {cnt} of yours with titles {blist}")

return app


# Create users and roles (and first blog!)
def create_users():
if current_app.testing:
return
security = current_app.security
security.datastore.db.create_all()
security.datastore.find_or_create_role(
name="admin",
permissions={"admin-read", "admin-write", "user-read", "user-write"},
)
security.datastore.find_or_create_role(
name="monitor", permissions={"admin-read", "user-read"}
)
security.datastore.find_or_create_role(
name="user", permissions={"user-read", "user-write"}
)
security.datastore.find_or_create_role(name="reader", permissions={"user-read"})

if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]", password=hash_password("password"), roles=["admin"]
with current_app.app_context():
security = current_app.security
security.datastore.db.create_all()
security.datastore.find_or_create_role(
name="admin",
permissions={"admin-read", "admin-write", "user-read", "user-write"},
)
if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]", password=hash_password("password"), roles=["monitor"]
security.datastore.find_or_create_role(
name="monitor", permissions={"admin-read", "user-read"}
)
real_user = security.datastore.find_user(email="[email protected]")
if not real_user:
real_user = security.datastore.create_user(
email="[email protected]", password=hash_password("password"), roles=["user"]
security.datastore.find_or_create_role(
name="user", permissions={"user-read", "user-write"}
)
if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]", password=hash_password("password"), roles=["reader"]
security.datastore.find_or_create_role(name="reader", permissions={"user-read"})

if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]",
password=hash_password("password"),
roles=["admin"],
)
if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]",
password=hash_password("password"),
roles=["monitor"],
)
real_user = security.datastore.find_user(email="[email protected]")
if not real_user:
real_user = security.datastore.create_user(
email="[email protected]", password=hash_password("password"), roles=["user"]
)
if not security.datastore.find_user(email="[email protected]"):
security.datastore.create_user(
email="[email protected]",
password=hash_password("password"),
roles=["reader"],
)

# create initial blog
blog = current_app.blog_cls(
title="First Blog", text="my first blog is short", user=real_user
)

# create initial blog
blog = app.blog_cls(
title="First Blog", text="my first blog is short", user=real_user
)
security.datastore.db.session.add(blog)
security.datastore.db.session.commit()
print(f"First blog id {blog.id}")


# Views
# Note that we always add @auth_required so that if a client isn't logged in
# we will get a proper '401' and redirected to login page.
@app.route("/")
@auth_required()
def home():
return render_template_string("Hello {{ current_user.email }}")


@app.route("/admin")
@auth_required()
@permissions_accepted("admin-read", "admin-write")
def admin():
return render_template_string(
"Hello on admin page. Current user {} password is {}".format(
current_user.email, current_user.password
)
)


@app.route("/ops")
@auth_required()
@roles_accepted("monitor")
def monitor():
# Example of using just a role. Note that 'admin' can't access this
# since it doesn't have the 'monitor' role - even though it has
# all the permissions that the 'monitor' role has.
return render_template_string("Hello OPS")


@app.route("/blog/<bid>", methods=["GET", "POST"])
@auth_required()
@permissions_required("user-write")
def update_blog(bid):
# Yes caller has write permission - but do they OWN this blog?
blog = current_app.blog_cls.query.get(bid)
if not blog:
abort(404)
if current_user != blog.user:
abort(403)
return render_template_string("Yes, {{ current_user.email }} can update blog")


@app.route("/myblogs", methods=["GET"])
@auth_required()
@permissions_accepted("user-read")
def list_my_blogs():
blogs = current_user.blogs
blist = ""
cnt = 0
for blog in blogs:
blist += f" {blog.title}"
cnt += 1
if not blogs:
abort(404)
return render_template_string(f"Found {cnt} of yours with titles {blist}")
security.datastore.db.session.add(blog)
security.datastore.db.session.commit()
print(f"First blog id {blog.id}")


if __name__ == "__main__":
with app.app_context():
myapp = create_app()
with myapp.app_context():
create_users()
app.run(port=5003)
myapp.run(port=5003)
Loading

0 comments on commit aba7b66

Please sign in to comment.