Three confirmed findings from the 2026-09-03 run, each independent.
GE-Enforce scope names are directory names on the imaging share, and create_scope
checked only that the name was non-empty. "../escape" wrote a manifest one level
above the share root, "../../escape2" two, and "/tmp/anywhere" wherever it liked
- proven by writing the files and then deleting them. The exposure is the
permission split rather than the traversal itself: geenforce.manage creates the
scope and geenforce.publish writes the file, so a manage-only author could aim a
publisher's write at any path that process can reach. Names are now a single
safe path segment, checked at creation AND again at the write (rows predating the
check exist, and the write is the line that acts), with the resolved target
confirmed to sit under the share root.
Agent timestamps were converted with astimezone(tz=None), which means the
SERVER's local zone, not UTC. An agent reporting 17:26:16Z was stored as
13:26:16 on this EDT box and re-served labelled 'Z', while the same row's
server-written lastreportedat stayed true UTC - one row carrying two time bases
four hours apart. The dashboard's 48-hour stability window compares a real UTC
cutoff against these values, so events left it four hours early. Invisible on a
UTC server, which is how it survived, and nothing in the Dockerfile, the compose
files or the Windows deploy pins TZ. AGENT-CONTRACT.md mandates UTC on the wire,
so a naive value is now taken as UTC rather than guessed at.
The three /api/maplevels reads went back to optional-auth. ADR-017 rule 6, the
API inventory (auth "none"), the module's own docstring and the comment in
PrinterInstallerMap.vue all say they are public, because the printer installer
map runs before anyone logs in and needs a blueprint to draw on. afce2437 swept
165 genuinely-open GET routes behind require_permission and caught these in the
net, so the anonymous installer map has drawn no floor plan since. Restored
rather than amending the ADR: blueprint paths and level names are not secrets,
and the asset positions that go on top already render on public kiosk pages.
The writes stay gated, and a test proves it.
Registered in PUBLIC_ENDPOINTS with the reason. That gate checks a public route
is DECLARED public; the new test checks it actually answers, which is the half
that was broken.
Ten tests. All three verified by planting: restore the non-empty scope check and
eight fail, restore the local-zone conversion and two fail, put the permission
back on the level list and one fails.
Scope: product
372 lines
16 KiB
Python
372 lines
16 KiB
Python
"""Buildings and levels: the drawings a marker can be placed on (ADR-017).
|
|
|
|
Reads are UNAUTHENTICATED. The printer installer map runs before anyone logs in
|
|
and needs a blueprint to draw, exactly as the printer install-list and the slide
|
|
feed already do. A level name, a blueprint path and a pixel size are not
|
|
secrets - the marker positions drawn on them already render on kiosk pages.
|
|
|
|
Writes require admin: adding a level changes where every marker on it appears.
|
|
"""
|
|
|
|
import os
|
|
|
|
from flask import Blueprint, request, current_app
|
|
from flask_jwt_extended import jwt_required
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import Building, MapLevel, Asset, AuditLog
|
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|
from shopdb.utils.authz import require_role
|
|
from shopdb.api import send_upload
|
|
from shopdb.utils.imagesize import image_size
|
|
from shopdb.utils.authz import require_permission
|
|
|
|
maplevels_bp = Blueprint('maplevels', __name__)
|
|
|
|
# Same set the map blueprint upload already accepts. SVG stays allowed because a
|
|
# floor plan is vector by nature; it is served through send_upload, which sends
|
|
# the sandbox headers that stop one executing as script.
|
|
BLUEPRINT_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
|
|
|
|
|
def _blueprint_dir():
|
|
return os.path.join(current_app.instance_path, 'maps')
|
|
|
|
|
|
# =============================================================================
|
|
# Read - public
|
|
#
|
|
# ADR-017 rule 6: the level list is readable without authentication, because the
|
|
# printer installer map runs optional-auth before anyone logs in and needs a
|
|
# blueprint. It follows the precedent of printer install-list and the slide
|
|
# feed, both still public.
|
|
#
|
|
# afce2437 ("Reading anything needs a permission") swept 165 open GET routes
|
|
# and caught these three in the net. Fail-closed, so nothing leaked - but the
|
|
# anonymous installer map has drawn no floor plan since, while this same file
|
|
# kept the docstring saying the reads are public, the API inventory kept
|
|
# declaring auth "none", and PrinterInstallerMap.vue kept its comment
|
|
# explaining why. Four places agreed and the code did not.
|
|
#
|
|
# Restored rather than amending the ADR: blueprint paths and level names are
|
|
# not secrets, the asset positions on them already render on public kiosk
|
|
# pages, and the feature the rule exists for is broken without it. These three
|
|
# are registered in tests/test_core/test_public_read_surface.py.
|
|
# =============================================================================
|
|
|
|
@maplevels_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_levels():
|
|
"""Every active level, grouped by building, in display order.
|
|
|
|
One call, because every consumer needs the whole list: the map switches
|
|
between levels, and a hover preview has to resolve an arbitrary asset's
|
|
level to a blueprint without knowing in advance which one it is.
|
|
"""
|
|
buildings = (Building.query.filter_by(isactive=True)
|
|
.order_by(Building.sortorder, Building.buildingid).all())
|
|
|
|
# How many markers sit on each level, in one grouped query rather than one
|
|
# per level. The admin page needs it to say what a rename or a resize is
|
|
# about to affect, and it is what makes the delete refusal predictable
|
|
# instead of a surprise.
|
|
counts = dict(db.session.query(Asset.levelid, db.func.count(Asset.assetid))
|
|
.filter(Asset.levelid.isnot(None), Asset.isactive.is_(True))
|
|
.group_by(Asset.levelid).all())
|
|
|
|
payload = []
|
|
for building in buildings:
|
|
entry = building.to_dict()
|
|
for level in entry.get('levels', []):
|
|
level['assetcount'] = counts.get(level['levelid'], 0)
|
|
payload.append(entry)
|
|
|
|
default = MapLevel.default_level()
|
|
return success_response({
|
|
'buildings': payload,
|
|
'defaultlevelid': default.levelid if default else None,
|
|
'totalplaced': sum(counts.values()),
|
|
})
|
|
|
|
|
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def get_level(levelid):
|
|
level = db.session.get(MapLevel, levelid)
|
|
if not level or not level.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
|
http_code=404)
|
|
return success_response(level.to_dict())
|
|
|
|
|
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def serve_blueprint(filename, levelid=None):
|
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
|
return send_upload(_blueprint_dir(), filename)
|
|
|
|
|
|
# =============================================================================
|
|
# Write - admin
|
|
# =============================================================================
|
|
|
|
@maplevels_bp.route('/buildings', methods=['POST'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def create_building():
|
|
data = request.get_json() or {}
|
|
name = (data.get('buildingname') or '').strip()
|
|
if not name:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'buildingname is required')
|
|
if Building.query.filter(db.func.lower(Building.buildingname)
|
|
== name.lower()).first():
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Building '{name}' already exists",
|
|
http_code=409)
|
|
building = Building(buildingname=name,
|
|
sortorder=int(data.get('sortorder') or 0))
|
|
db.session.add(building)
|
|
db.session.commit()
|
|
AuditLog.log('created', 'Building', entityid=building.buildingid,
|
|
entityname=name)
|
|
db.session.commit()
|
|
return success_response(building.to_dict(), message='Building created',
|
|
http_code=201)
|
|
|
|
|
|
@maplevels_bp.route('/buildings/<int:buildingid>', methods=['PUT', 'PATCH'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def update_building(buildingid):
|
|
"""Rename or reorder a building. Levels move with it; nothing repositions."""
|
|
building = db.session.get(Building, buildingid)
|
|
if not building:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
|
http_code=404)
|
|
data = request.get_json() or {}
|
|
if 'buildingname' in data:
|
|
name = (data['buildingname'] or '').strip()
|
|
if not name:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'buildingname cannot be blank')
|
|
clash = Building.query.filter(
|
|
db.func.lower(Building.buildingname) == name.lower(),
|
|
Building.buildingid != buildingid).first()
|
|
if clash:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f"Building '{name}' already exists",
|
|
http_code=409)
|
|
building.buildingname = name
|
|
if data.get('sortorder') is not None:
|
|
building.sortorder = int(data['sortorder'])
|
|
if 'isactive' in data:
|
|
building.isactive = bool(data['isactive'])
|
|
db.session.commit()
|
|
AuditLog.log('updated', 'Building', entityid=buildingid,
|
|
entityname=building.buildingname)
|
|
db.session.commit()
|
|
return success_response(building.to_dict(), message='Building updated')
|
|
|
|
|
|
@maplevels_bp.route('', methods=['POST'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def create_level():
|
|
data = request.get_json() or {}
|
|
name = (data.get('levelname') or '').strip()
|
|
buildingid = data.get('buildingid')
|
|
if not name or not buildingid:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'buildingname and buildingid are required')
|
|
if not db.session.get(Building, buildingid):
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
|
http_code=404)
|
|
|
|
level = MapLevel(
|
|
buildingid=buildingid,
|
|
levelname=name,
|
|
sortorder=int(data.get('sortorder') or 0),
|
|
blueprintlight=data.get('blueprintlight'),
|
|
blueprintdark=data.get('blueprintdark'),
|
|
mapwidth=int(data.get('mapwidth') or 3300),
|
|
mapheight=int(data.get('mapheight') or 2550),
|
|
)
|
|
db.session.add(level)
|
|
db.session.flush()
|
|
_apply_default(level, data.get('isdefault'))
|
|
db.session.commit()
|
|
AuditLog.log('created', 'MapLevel', entityid=level.levelid,
|
|
entityname=name)
|
|
db.session.commit()
|
|
return success_response(level.to_dict(), message='Level created',
|
|
http_code=201)
|
|
|
|
|
|
@maplevels_bp.route('/<int:levelid>', methods=['PUT', 'PATCH'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def update_level(levelid):
|
|
level = db.session.get(MapLevel, levelid)
|
|
if not level:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
|
http_code=404)
|
|
data = request.get_json() or {}
|
|
|
|
# The dimensions are the coordinate space every marker on this level is
|
|
# expressed in, so changing them moves every marker relative to the image.
|
|
# Report it rather than doing it silently; the caller decides whether to run
|
|
# a transform (POST /api/assets/map/transform).
|
|
warnings = []
|
|
for field in ('mapwidth', 'mapheight'):
|
|
if field in data and int(data[field] or 0) != getattr(level, field):
|
|
warnings.append(
|
|
'%s changed from %s to %s; existing positions on this level are '
|
|
'still in the old coordinate space' % (field,
|
|
getattr(level, field),
|
|
data[field]))
|
|
|
|
for field in ('levelname', 'blueprintlight', 'blueprintdark'):
|
|
if field in data:
|
|
setattr(level, field, data[field])
|
|
for field in ('sortorder', 'mapwidth', 'mapheight'):
|
|
if field in data and data[field] is not None:
|
|
setattr(level, field, int(data[field]))
|
|
if 'isactive' in data:
|
|
level.isactive = bool(data['isactive'])
|
|
_apply_default(level, data.get('isdefault'))
|
|
|
|
db.session.commit()
|
|
AuditLog.log('updated', 'MapLevel', entityid=level.levelid,
|
|
entityname=level.levelname)
|
|
db.session.commit()
|
|
payload = level.to_dict()
|
|
if warnings:
|
|
payload['warnings'] = warnings
|
|
return success_response(payload, message='Level updated')
|
|
|
|
|
|
@maplevels_bp.route('/<int:levelid>', methods=['DELETE'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def delete_level(levelid):
|
|
"""Deactivate a level, refusing while assets are still placed on it.
|
|
|
|
Deleting the drawing out from under a marker would leave a position in a
|
|
coordinate space that no longer exists - unrenderable, and indistinguishable
|
|
from a marker that was never placed.
|
|
"""
|
|
level = db.session.get(MapLevel, levelid)
|
|
if not level:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
|
http_code=404)
|
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
|
if placed:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f'{placed} asset(s) are placed on this level. Move them to another '
|
|
f'level first.', http_code=409)
|
|
if level.isdefault:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'This is the default level. Make another level the default first.')
|
|
level.isactive = False
|
|
db.session.commit()
|
|
AuditLog.log('deleted', 'MapLevel', entityid=levelid,
|
|
entityname=level.levelname)
|
|
db.session.commit()
|
|
return success_response(message='Level deactivated')
|
|
|
|
|
|
@maplevels_bp.route('/<int:levelid>/blueprint', methods=['POST'])
|
|
@jwt_required()
|
|
@require_role('admin')
|
|
def upload_blueprint(levelid):
|
|
"""Upload this level's blueprint for one theme.
|
|
|
|
multipart/form-data: file=<image>, theme=light|dark. The native pixel size
|
|
is NOT inferred from the image - it is stated on the level, because that is
|
|
what existing coordinates mean and guessing it would move every marker.
|
|
"""
|
|
level = db.session.get(MapLevel, levelid)
|
|
if not level:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
|
http_code=404)
|
|
theme = (request.form.get('theme') or '').strip().lower()
|
|
if theme not in ('light', 'dark'):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'theme must be light or dark')
|
|
upload = request.files.get('file')
|
|
if not upload or not upload.filename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
|
ext = os.path.splitext(upload.filename)[1].lower()
|
|
if ext not in BLUEPRINT_EXTENSIONS:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unsupported image type {ext}')
|
|
|
|
os.makedirs(_blueprint_dir(), exist_ok=True)
|
|
filename = secure_filename(f'level-{levelid}-{theme}{ext}')
|
|
raw = upload.read()
|
|
upload.seek(0)
|
|
upload.save(os.path.join(_blueprint_dir(), filename))
|
|
url = f'/api/maplevels/{levelid}/blueprint/{filename}'
|
|
setattr(level, f'blueprint{theme}', url)
|
|
|
|
# The image's real pixel size, read from its header. What happens next
|
|
# depends entirely on whether anything is already placed on this level.
|
|
detectedwidth, detectedheight = image_size(raw)
|
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
|
sizenote = None
|
|
if detectedwidth and detectedheight:
|
|
matches = (detectedwidth == level.mapwidth
|
|
and detectedheight == level.mapheight)
|
|
if matches:
|
|
sizenote = None
|
|
elif not placed:
|
|
# Nothing is placed here yet, so no coordinate can be invalidated:
|
|
# adopt the image's own size, which is almost certainly what the
|
|
# operator wanted and saves them typing it.
|
|
level.mapwidth = detectedwidth
|
|
level.mapheight = detectedheight
|
|
sizenote = ('dimensions set from the image: %d x %d'
|
|
% (detectedwidth, detectedheight))
|
|
else:
|
|
# Markers exist in the OLD coordinate space. Silently adopting the
|
|
# new size would move every one of them relative to the drawing
|
|
# while looking like a successful upload, so this reports and
|
|
# changes nothing. Resizing is a transform, not an upload.
|
|
sizenote = (
|
|
'this image is %d x %d but the level is set to %d x %d, and %d '
|
|
'marker(s) are placed in the current space. The dimensions were '
|
|
'NOT changed: set them with a landmark transform '
|
|
'(POST /api/mappositions/transform) so the markers move with '
|
|
'them.' % (detectedwidth, detectedheight, level.mapwidth,
|
|
level.mapheight, placed))
|
|
|
|
db.session.commit()
|
|
AuditLog.log('updated', 'MapLevel', entityid=levelid,
|
|
entityname=level.levelname,
|
|
changes={f'blueprint{theme}': {'new': url}})
|
|
db.session.commit()
|
|
payload = {'levelid': levelid, f'blueprint{theme}': url,
|
|
'mapwidth': level.mapwidth, 'mapheight': level.mapheight,
|
|
'detectedwidth': detectedwidth, 'detectedheight': detectedheight,
|
|
'placedassets': placed}
|
|
if sizenote:
|
|
payload['sizenote'] = sizenote
|
|
return success_response(payload, message='Blueprint uploaded')
|
|
|
|
|
|
def _apply_default(level, requested):
|
|
"""Make this level the default, clearing the flag elsewhere.
|
|
|
|
Exactly-one-default is a rule across rows, which no column constraint can
|
|
express, so it is enforced here - the one place that sets the flag.
|
|
"""
|
|
if not requested:
|
|
return
|
|
MapLevel.query.filter(MapLevel.levelid != level.levelid).update(
|
|
{'isdefault': False})
|
|
level.isdefault = True
|