Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
This commit is contained in:
@@ -14,7 +14,7 @@ from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
@@ -42,9 +42,25 @@ ALLOWEDIMAGETYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _currentuser():
|
||||
"""The signed-in user, or None.
|
||||
|
||||
`current_user` is a LocalProxy, so `current_user is not None` is True even
|
||||
when nobody is signed in - the proxy exists, the user behind it does not.
|
||||
Under a required JWT that never showed; the read routes are jwt-optional
|
||||
now, and the first guest request found it by way of
|
||||
"'NoneType' object has no attribute 'haspermission'".
|
||||
"""
|
||||
try:
|
||||
return current_user._get_current_object()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _userid():
|
||||
if current_user is not None:
|
||||
return current_user.userid
|
||||
user = _currentuser()
|
||||
if user is not None:
|
||||
return user.userid
|
||||
try:
|
||||
return int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
@@ -59,8 +75,12 @@ def _has(permission):
|
||||
the decorator makes, including the admin bypass that lives inside
|
||||
haspermission.
|
||||
"""
|
||||
return bool(current_user is not None
|
||||
and current_user.haspermission(permission))
|
||||
user = _currentuser()
|
||||
return bool(user is not None and user.haspermission(permission))
|
||||
|
||||
|
||||
def _signedin():
|
||||
return _currentuser() is not None
|
||||
|
||||
|
||||
def _mayedit(guide):
|
||||
@@ -101,7 +121,6 @@ def _slugify(title, guideid=None):
|
||||
# ---------------------------------------------------------------- reading ---
|
||||
|
||||
@guides_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_guides():
|
||||
"""Published guides, plus the caller's own drafts if they can author.
|
||||
@@ -109,9 +128,15 @@ def list_guides():
|
||||
A half-written procedure on a shop floor is worse than no procedure, so
|
||||
drafts are invisible to readers - but an author must be able to find their
|
||||
own, or drafting is a trap door.
|
||||
|
||||
A guest reaches this only if the guest role has been granted guides.view.
|
||||
A guest is never an author, so the draft branches below cannot open for one.
|
||||
"""
|
||||
caneditall = _has('guides.edit')
|
||||
mine = _userid()
|
||||
# A guest has no user id, and `createdby == None` is a real match in SQL
|
||||
# against every guide imported without an author. Their drafts are not
|
||||
# theirs; nobody's are.
|
||||
mine = _userid() if _signedin() else -1
|
||||
|
||||
# Match against the PUBLISHED text for anything the caller is only allowed
|
||||
# to read, and against the working copy only for guides they may edit.
|
||||
@@ -185,7 +210,6 @@ def list_guides():
|
||||
|
||||
|
||||
@guides_bp.route('/tags', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips.
|
||||
@@ -215,7 +239,6 @@ def list_tags():
|
||||
|
||||
|
||||
@guides_bp.route('/<slug>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_guide(slug):
|
||||
"""What a reader gets: the published revision.
|
||||
@@ -257,7 +280,6 @@ def read_guide(slug):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_working(guideid):
|
||||
"""The working copy, for the editor."""
|
||||
@@ -275,7 +297,6 @@ def read_working(guideid):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -298,7 +319,6 @@ def list_revisions(guideid):
|
||||
|
||||
|
||||
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
"""One revision by id.
|
||||
@@ -327,8 +347,40 @@ def read_revision(revisionid):
|
||||
|
||||
# ---------------------------------------------------------------- writing ---
|
||||
|
||||
def _attachimages(guide, blocks):
|
||||
"""Attach the images this guide's blocks reference but nothing owns yet.
|
||||
|
||||
An image pasted into a guide that has not been saved is uploaded with no
|
||||
guideid - there is no id to give it. The upload route says the editor
|
||||
attaches it on save, and the editor never did: it only sends a guideid when
|
||||
the guide already has one. So a screenshot pasted while writing a NEW guide
|
||||
stayed unattached forever.
|
||||
|
||||
An unattached image is visible to editors and to whoever uploaded it, and
|
||||
to nobody else - so the guide looked right to the person who wrote it and
|
||||
showed "Image unavailable" to every reader. Attaching on save is what makes
|
||||
the image belong to the guide, and therefore readable by anyone allowed to
|
||||
read the guide once it is published.
|
||||
|
||||
Only images nothing else owns are claimed: an image already attached to
|
||||
another guide is left alone rather than stolen by whoever references it.
|
||||
"""
|
||||
referenced = set()
|
||||
for block in blocks or []:
|
||||
if isinstance(block, dict) and block.get('imageid') is not None:
|
||||
try:
|
||||
referenced.add(int(block['imageid']))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not referenced:
|
||||
return
|
||||
(GuideImage.query
|
||||
.filter(GuideImage.guideimageid.in_(referenced))
|
||||
.filter(GuideImage.guideid.is_(None))
|
||||
.update({'guideid': guide.guideid}, synchronize_session=False))
|
||||
|
||||
|
||||
@guides_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def create_guide():
|
||||
data = request.get_json(silent=True) or {}
|
||||
@@ -353,13 +405,15 @@ def create_guide():
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
# The first save is exactly when a pasted image has no owner yet.
|
||||
_attachimages(guide, guide.blocks)
|
||||
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Guide created')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def update_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -387,6 +441,8 @@ def update_guide(guideid):
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
_attachimages(guide, guide.blocks)
|
||||
|
||||
# The slug is set once, at create, and does NOT follow the title.
|
||||
#
|
||||
# _slugify's own docstring says the slug is stored so that renaming cannot
|
||||
@@ -409,7 +465,6 @@ def update_guide(guideid):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def publish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -425,7 +480,6 @@ def publish_guide(guideid):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def unpublish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -437,7 +491,6 @@ def unpublish_guide(guideid):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def restore_revision(guideid, revisionid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -458,7 +511,6 @@ def restore_revision(guideid, revisionid):
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.delete')
|
||||
def delete_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
@@ -472,7 +524,6 @@ def delete_guide(guideid):
|
||||
# ----------------------------------------------------------------- images ---
|
||||
|
||||
@guides_bp.route('/images', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def upload_image():
|
||||
"""Take a pasted or dropped image.
|
||||
@@ -553,9 +604,16 @@ def _mayseeimage(image):
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def serve_image(imageid):
|
||||
"""The image itself, under the same rule as the guide that holds it.
|
||||
|
||||
A guest who may read a published guide must see its screenshots, or every
|
||||
image renders as "Image unavailable" and the guide looks broken.
|
||||
_mayseeimage already answers correctly for somebody holding nothing - it
|
||||
requires the PUBLISHED revision to reference the image - so a guest needs no
|
||||
separate branch here.
|
||||
"""
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
|
||||
@@ -114,6 +114,52 @@ def build_cli():
|
||||
click.echo(' imported as /guides/{} (draft, revision {})'
|
||||
.format(guide.slug, guide.revision))
|
||||
|
||||
@guides_cli.command('fix-images')
|
||||
@click.option('--apply/--dry-run', default=False,
|
||||
help='Write to the database. The default reports only.')
|
||||
@with_appcontext
|
||||
def fix_images(apply):
|
||||
"""Attach images a guide references but nothing owns.
|
||||
|
||||
An image pasted into a guide that had not been saved yet was uploaded
|
||||
with no guideid, and nothing ever attached it. Editors and the uploader
|
||||
could see it; every other reader got "Image unavailable" on a published
|
||||
guide. Saving a guide attaches its images now, but a guide nobody edits
|
||||
again would stay broken, so this repairs what is already stored.
|
||||
"""
|
||||
orphans = GuideImage.query.filter(GuideImage.guideid.is_(None)).all()
|
||||
if not orphans:
|
||||
click.echo('No unattached images.')
|
||||
return
|
||||
|
||||
wanted = {}
|
||||
for guide in Guide.query.all():
|
||||
for block in guide.blocks or []:
|
||||
if isinstance(block, dict) and block.get('imageid') is not None:
|
||||
try:
|
||||
wanted.setdefault(int(block['imageid']), guide)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
fixed = 0
|
||||
for image in orphans:
|
||||
guide = wanted.get(image.guideimageid)
|
||||
if guide is None:
|
||||
click.echo(' image {} is referenced by no guide - left alone'
|
||||
.format(image.guideimageid))
|
||||
continue
|
||||
click.echo(' image {} -> guide {} ({})'.format(
|
||||
image.guideimageid, guide.guideid, guide.slug))
|
||||
if apply:
|
||||
image.guideid = guide.guideid
|
||||
fixed += 1
|
||||
|
||||
if not apply:
|
||||
click.echo('{} would be attached. Pass --apply to write.'.format(fixed))
|
||||
return
|
||||
db.session.commit()
|
||||
click.echo('{} image(s) attached.'.format(fixed))
|
||||
|
||||
@guides_cli.command('export')
|
||||
@click.option('--out', 'outdir', required=True,
|
||||
type=click.Path(file_okay=False),
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
* checkboxes are live in exactly one of them.
|
||||
*
|
||||
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
|
||||
* plugin is disabled. Every route requires auth because every guides API route
|
||||
* is behind JWT plus a guides.* permission.
|
||||
* plugin is disabled.
|
||||
*
|
||||
* READING does not require auth; WRITING does. The read routes are gated on
|
||||
* guides.view, and a signed-out visitor holds whatever the GUEST role has been
|
||||
* granted - so the role decides, not the router. Sending a guest to /login when
|
||||
* the site has granted them guides.view would make the grant look broken;
|
||||
* letting them through when it has not costs one 401 the view already handles.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'guides',
|
||||
name: 'guides',
|
||||
component: () => import('./views/GuidesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
meta: { plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/new',
|
||||
@@ -26,7 +31,7 @@ export default [
|
||||
path: 'guides/:slug',
|
||||
name: 'guide-view',
|
||||
component: () => import('./views/GuideView.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
meta: { plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/:guideid/edit',
|
||||
|
||||
@@ -90,6 +90,10 @@ class GuidesPlugin(BasePlugin):
|
||||
'icon': 'book-open',
|
||||
'route': '/guides',
|
||||
'position': 8,
|
||||
# The nav endpoint hides an item whose permission the caller
|
||||
# does not hold, so a signed-out visitor sees Guides only when
|
||||
# the guest role has been granted guides.view.
|
||||
'permission': 'guides.view',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user