Files
LinkDesk/backend/migrate_role_permissions.py
indigo 960753b3d6 Make note/submission edit-own and edit-others' permissions explicit
Split note:edit/note:delete and submission:edit/submission:delete into
four independent permissions each - edit_self/delete_self (acting on
your own note or submission) and edit_other/delete_other (acting on
someone else's). Previously "own" access was an unconditional, unrevokable
ownership check with no permission behind it, and a prior round had
accidentally granted coordinator submission:edit/delete by default
(inconsistent with notes, which were correctly own-only) - both are fixed
here: self-service now goes through a real, default-granted-to-everyone
permission, and acting on someone else's note/submission is an explicit
elevated grant that nobody gets by default.

The Role Management permission editor now shows "Edit Own / Delete Own /
Edit Others' / Delete Others'" as four clear, independently toggleable
options instead of one ambiguous "Edit"/"Delete" checkbox.

migrate_role_permissions.py renames the existing permission rows in place
(rather than leaving orphaned duplicates) and includes a one-time,
idempotent correction that revokes the earlier over-grant from coordinator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 21:17:47 +08:00

255 lines
12 KiB
Python

#!/usr/bin/env python3
"""
One-off migration script for the multi-role permission system.
Creates the new roles/permissions/role_permissions/user_roles tables, seeds
the permission catalog (asset/shot/task create/edit/delete, plus assignment,
review, submission, upload, and note actions), seeds the 4 system roles
(coordinator/director/artist/developer) with role_permissions matching
today's actual behavior, then backfills user_roles from each existing
user's legacy `role` column.
Does not read or write users.role or users.is_admin beyond reading `role` to
resolve which system Role to link. Safe to re-run (every step is idempotent
and purely additive - re-running after adding new PERMISSIONS/grants below
only inserts what's missing, never removes or resets existing data).
"""
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from database import DATABASE_URL, Base
from models.user import User
from models.role import Role, Permission
import logging
import models # noqa: F401 - ensures every model is registered on Base.metadata
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# The full permission catalog: (resource, action, description).
PERMISSIONS = [
("asset", "create", "Can create assets"),
("asset", "edit", "Can edit assets"),
("asset", "delete", "Can delete assets"),
("shot", "create", "Can create shots"),
("shot", "edit", "Can edit shots"),
("shot", "delete", "Can delete shots"),
("task", "create", "Can create tasks"),
("task", "edit", "Can edit tasks"),
("task", "delete", "Can delete tasks"),
("task", "change_status", "Can change task status"),
("assignment", "create", "Can assign a task to a user"),
("assignment", "edit", "Can reassign a task to a different user"),
("assignment", "delete", "Can unassign a task"),
("review", "publish", "Can approve a submission"),
("review", "retake", "Can request a retake on a submission"),
("submission", "create", "Can submit work for a task"),
("submission", "edit_self", "Can edit your own submission notes"),
("submission", "delete_self", "Can delete your own submission"),
("submission", "edit_other", "Can edit another user's submission notes"),
("submission", "delete_other", "Can delete another user's submission"),
("upload", "create", "Can upload task attachments"),
("upload", "delete", "Can delete task attachments"),
("note", "create", "Can add task notes"),
("note", "edit_self", "Can edit your own note"),
("note", "delete_self", "Can delete your own note"),
("note", "edit_other", "Can edit another user's note"),
("note", "delete_other", "Can delete another user's note"),
("note", "view_internal", "Can view internal notes"),
("note", "view_client", "Can view client notes"),
]
SYSTEM_ROLE_DESCRIPTIONS = {
"coordinator": "Can create, edit, and delete assets, shots, and tasks.",
"director": "Director role, migrated from the legacy single-role system.",
"artist": "Artist role, migrated from the legacy single-role system.",
"developer": "Developer role, migrated from the legacy single-role system.",
}
# Explicit per-role grants, faithful to today's actual behavior for each
# action (not a blanket "coordinator gets everything" - e.g. director
# already has real elevated rights on review publish/retake today, and
# non-artist roles already have unrestricted note/upload create today).
# submission:create is granted to nobody by default since today it's a pure
# ownership check (must literally be the assigned user), not role-based -
# is_admin still bypasses it, and self-service is preserved in the endpoint.
# note:view_internal goes to everyone (matches today - all notes are visible
# to anyone with task access); note:view_client is new/more restrictive by
# default, granted only to coordinator+director.
# task:change_status is granted to coordinator only. This deliberately
# resolves a pre-existing inconsistency: the single-task status endpoint only
# allowed COORDINATOR (+artist self-service), while the bulk endpoint also
# allowed DIRECTOR - now both use this one permission, so director loses the
# bulk-only status-change access they had before (can be re-granted via a
# custom role if that capability is actually wanted).
# note/submission edit_self+delete_self are granted to every system role -
# today, editing/deleting your OWN note or submission is an unconditional
# right with no role check at all, so faithful migration means everyone
# gets it by default. edit_other/delete_other (acting on someone ELSE's
# note or submission) are the elevated capability and are granted to
# nobody by default - not even coordinator.
SELF_SERVICE_GRANTS = {
("note", "edit_self"), ("note", "delete_self"),
("submission", "edit_self"), ("submission", "delete_self"),
}
SYSTEM_ROLE_GRANTS = {
"coordinator": SELF_SERVICE_GRANTS | {
("asset", "create"), ("asset", "edit"), ("asset", "delete"),
("shot", "create"), ("shot", "edit"), ("shot", "delete"),
("task", "create"), ("task", "edit"), ("task", "delete"), ("task", "change_status"),
("assignment", "create"), ("assignment", "edit"), ("assignment", "delete"),
("review", "publish"), ("review", "retake"),
("upload", "create"), ("upload", "delete"),
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
},
"director": SELF_SERVICE_GRANTS | {
("review", "publish"), ("review", "retake"),
("upload", "create"),
("note", "create"), ("note", "view_internal"), ("note", "view_client"),
},
"artist": SELF_SERVICE_GRANTS | {
("note", "view_internal"),
},
"developer": SELF_SERVICE_GRANTS | {
("upload", "create"),
("note", "create"), ("note", "view_internal"),
},
}
def migrate_role_permissions():
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# 0. Add note_type to production_notes if missing (create_all only
# creates missing tables, it doesn't alter existing ones).
try:
db.execute(text("SELECT note_type FROM production_notes LIMIT 1"))
logger.info("note_type column already exists")
except Exception:
db.rollback()
logger.info("Adding note_type column to production_notes table")
db.execute(text(
"ALTER TABLE production_notes ADD COLUMN note_type VARCHAR(8) NOT NULL DEFAULT 'internal'"
))
db.commit()
# 0b. One-time rename: note/submission edit and delete were renamed
# to edit_other/delete_other to make explicit that they only govern
# acting on someone else's note or submission (your own is always
# editable/deletable via ownership, no permission needed). Renaming
# the existing rows in place (rather than leaving the old ones
# orphaned and adding new ones) preserves their ids; idempotent -
# a no-op once renamed. Not a general rename mechanism.
RENAMES = [
("note", "edit", "edit_other", "Can edit another user's note"),
("note", "delete", "delete_other", "Can delete another user's note"),
("submission", "edit", "edit_other", "Can edit another user's submission notes"),
("submission", "delete", "delete_other", "Can delete another user's submission"),
]
renamed = 0
for resource, old_action, new_action, new_description in RENAMES:
perm = db.query(Permission).filter(
Permission.resource == resource, Permission.action == old_action
).first()
if perm:
perm.action = new_action
perm.description = new_description
renamed += 1
db.commit()
if renamed:
logger.info(f"Permissions: {renamed} renamed to *_other")
# 1. Seed permissions (idempotent by resource+action)
permissions_by_key = {}
created_permissions = 0
for resource, action, description in PERMISSIONS:
perm = db.query(Permission).filter(
Permission.resource == resource, Permission.action == action
).first()
if not perm:
perm = Permission(resource=resource, action=action, description=description)
db.add(perm)
db.flush()
created_permissions += 1
permissions_by_key[(resource, action)] = perm
logger.info(f"Permissions: {created_permissions} created, {len(permissions_by_key)} total")
# 2. Seed system roles (idempotent by name)
roles_by_name = {}
created_roles = 0
for name, description in SYSTEM_ROLE_DESCRIPTIONS.items():
role = db.query(Role).filter(Role.name == name).first()
if not role:
role = Role(name=name, description=description, is_system=True)
db.add(role)
db.flush()
created_roles += 1
roles_by_name[name] = role
logger.info(f"System roles: {created_roles} created, {len(roles_by_name)} total")
# 3. Link role_permissions (idempotent - only add missing links)
linked = 0
for name, grants in SYSTEM_ROLE_GRANTS.items():
role = roles_by_name[name]
for key in grants:
perm = permissions_by_key[key]
if perm not in role.permissions:
role.permissions.append(perm)
linked += 1
db.commit()
logger.info(f"role_permissions: {linked} new links created")
# 3b. One-time correction: an earlier version of this script granted
# coordinator submission:edit/submission:delete by default, which was
# wrong (those should be own-only by default, same as note:edit/
# delete). Revoke them if still present, idempotent - a no-op once
# corrected. Not a general revoke mechanism, just fixing this one
# past mistake.
coordinator = roles_by_name.get("coordinator")
revoked = 0
if coordinator:
for key in [("submission", "edit"), ("submission", "delete")]:
perm = permissions_by_key.get(key)
if perm and perm in coordinator.permissions:
coordinator.permissions.remove(perm)
revoked += 1
db.commit()
if revoked:
logger.info(f"role_permissions: {revoked} over-grant(s) revoked from coordinator (submission edit/delete correction)")
# 4. Backfill user_roles from users.role (idempotent - only add missing links)
users = db.query(User).all()
backfilled = 0
for user in users:
role = roles_by_name.get(user.role.value)
if role and role not in user.roles:
user.roles.append(role)
backfilled += 1
db.commit()
logger.info(f"user_roles: {backfilled} new links created for {len(users)} users")
# Verification summary
logger.info("--- Migration summary ---")
for name, role in roles_by_name.items():
logger.info(f" Role '{name}': is_system={role.is_system}, permissions={len(role.permissions)}, users={len(role.users)}")
except Exception as e:
logger.error(f"Migration failed: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
logger.info("Starting role/permission migration...")
migrate_role_permissions()
logger.info("Migration completed!")