Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions querybook/migrations/versions/1e477e1b711a_commit_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""add execute permission level for datadocs

Revision ID: 1e477e1b711a
Revises: e7a17a8acf4c
Create Date: 2025-10-02 00:32:17.602058

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = '1e477e1b711a'
down_revision = 'e7a17a8acf4c'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('data_doc_editor', sa.Column('execute', sa.Boolean(), nullable=False, server_default='0'))


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('data_doc_editor', 'execute')
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions querybook/server/const/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@

class BoardDataDocPermission(Enum):
READ = "read"
EXECUTE = "execute"
WRITE = "write"
16 changes: 12 additions & 4 deletions querybook/server/datasources/datadoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from logic.datadoc_permission import (
assert_can_read,
assert_can_write,
assert_can_execute,
assert_is_owner,
assert_is_not_group,
)
Expand Down Expand Up @@ -385,8 +386,8 @@ def run_data_doc(id):


@register("/datadoc/<int:id>/run/", methods=["POST"])
def adhoc_run_data_doc(id, send_notification=False):
assert_can_write(id)
def adhoc_run_data_doc(id, start_index=0, send_notification=False):
assert_can_execute(id)
verify_data_doc_permission(id)

notifier_name = get_user_preferred_notifier(current_user.id)
Expand Down Expand Up @@ -443,12 +444,18 @@ def add_datadoc_editor(
uid,
read=None,
write=None,
execute=None,
originator=None, # Used for websocket to identify sender, optional
):
with DBSession() as session:
assert_can_write(doc_id, session=session)
editor = logic.create_data_doc_editor(
data_doc_id=doc_id, uid=uid, read=read, write=write, commit=False
data_doc_id=doc_id,
uid=uid,
read=read,
write=write,
execute=execute,
commit=False,
)
editor_dict = editor.to_dict()

Expand Down Expand Up @@ -577,14 +584,15 @@ def update_datadoc_editor(
id,
write=None,
read=None,
execute=None,
originator=None, # Used for websocket to identify sender, optional
):
with DBSession() as session:
editor = logic.get_data_doc_editor_by_id(id, session=session)
if editor:
assert_can_write(editor.data_doc_id, session=session)

editor = logic.update_data_doc_editor(id, read, write, session=session)
editor = logic.update_data_doc_editor(id, read, write, execute, session=session)
if editor:
editor_dict = editor.to_dict()
socketio.emit(
Expand Down
3 changes: 2 additions & 1 deletion querybook/server/datasources/query_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
user as user_logic,
admin as admin_logic,
)
from logic.datadoc_permission import user_can_read
from logic.datadoc_permission import user_can_read, assert_can_execute
from logic.query_execution_permission import (
get_default_user_environment_by_execution_id,
)
Expand Down Expand Up @@ -153,6 +153,7 @@ def create_query_execution(
data_cell_id=data_cell_id,
session=session,
)
assert_can_execute(data_doc.id, session=session)

try:
initiate_query_execution(
Expand Down
12 changes: 8 additions & 4 deletions querybook/server/logic/datadoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,12 +873,13 @@ def get_data_doc_editors_by_doc_id(data_doc_id, session=None):

return [
DataDocEditor(
# [0] is id, [1] is uid, [2] is read, [3] is write
# [0] is id, [1] is uid, [2] is read, [3] is write, [4] is execute
data_doc_id=data_doc_id,
id=editor[0],
uid=editor[1],
read=editor[2],
write=editor[3],
execute=editor[4],
)
for editor in editors
]
Expand All @@ -891,15 +892,17 @@ def get_data_doc_writers_by_doc_id(doc_id, session=None):

@with_session
def create_data_doc_editor(
data_doc_id, uid, read=False, write=False, commit=True, session=None
data_doc_id, uid, read=False, write=False, execute=False, commit=True, session=None
):
existing_editor = (
session.query(DataDocEditor).filter_by(data_doc_id=data_doc_id, uid=uid).first()
)
if existing_editor is not None:
return update_data_doc_editor(existing_editor.id, read, write, session=session)

editor = DataDocEditor(data_doc_id=data_doc_id, uid=uid, read=read, write=write)
editor = DataDocEditor(
data_doc_id=data_doc_id, uid=uid, read=read, write=write, execute=execute
)

session.add(editor)
if commit:
Expand All @@ -917,14 +920,15 @@ def update_data_doc_editor(
id,
read=None,
write=None,
execute=None,
commit=True,
session=None,
**fields,
):
editor = get_data_doc_editor_by_id(id, session=session)
if editor:
updated = update_model_fields(
editor, skip_if_value_none=True, read=read, write=write
editor, skip_if_value_none=True, read=read, write=write, execute=execute
)

if updated:
Expand Down
16 changes: 14 additions & 2 deletions querybook/server/logic/datadoc_collab.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from datasources.github import with_github_client
from logic import datadoc as logic
from logic import user as user_logic
from logic.datadoc_permission import assert_can_read, assert_can_write
from logic.datadoc_permission import (
assert_can_read,
assert_can_write,
assert_can_execute,
)
from flask_login import current_user


Expand All @@ -25,7 +29,15 @@ def get_datadoc(doc_id, session=None):
@with_session
def update_datadoc(doc_id, fields, sid="", session=None):
# Check to see if author has permission
assert_can_write(doc_id, session=session)
# For variable editing (meta field only), allow execute permission
# For all other fields, require write permission
field_names = set(fields.keys())
if field_names == {"meta"}:
assert_can_execute(doc_id, session=session)
else:
# Updating other fields, require write permission
assert_can_write(doc_id, session=session)

verify_data_doc_permission(doc_id, session=session)
doc = logic.update_data_doc(
id=doc_id,
Expand Down
27 changes: 27 additions & 0 deletions querybook/server/logic/datadoc_permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ def user_can_read(doc_id, uid, session=None):
)


@with_session
def user_can_execute(doc_id, uid, session=None):
datadoc = session.query(DataDoc).filter_by(id=doc_id).first()

if datadoc is None:
raise DocDoesNotExist()

if datadoc.owner_uid == uid:
return True

return user_has_permission(
doc_id, BoardDataDocPermission.EXECUTE, DataDocEditor, uid, session=session
)


@with_session
def assert_can_read(doc_id, session=None):
try:
Expand All @@ -72,6 +87,18 @@ def assert_can_write(doc_id, session=None):
api_assert(False, "DOC_DNE", RESOURCE_NOT_FOUND_STATUS_CODE)


@with_session
def assert_can_execute(doc_id, session=None):
try:
api_assert(
user_can_execute(doc_id, uid=current_user.id, session=session),
"CANNOT_EXECUTE_DATADOC",
UNAUTHORIZED_STATUS_CODE,
)
except DocDoesNotExist:
api_assert(False, "DOC_DNE", RESOURCE_NOT_FOUND_STATUS_CODE)


@with_session
def assert_is_owner(doc_id, session=None):
try:
Expand Down
95 changes: 62 additions & 33 deletions querybook/server/logic/generic_permission.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import List, Optional, Tuple, Union
from app.db import with_session
from models import UserGroupMember, User, DataDocEditor, BoardEditor
from sqlalchemy import func, select, Integer
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from const.permissions import BoardDataDocPermission

Expand All @@ -12,7 +12,7 @@ def get_all_groups_and_group_members_with_access(
editor_type: Union[DataDocEditor, BoardEditor],
uid: Optional[int] = None,
session: Optional[Session] = None,
) -> List[Tuple[int, int, bool, bool]]:
) -> List[Tuple[int, int, bool, bool, bool]]:
"""
Get all groups and group members with access to a DataDoc or Board.

Expand All @@ -23,17 +23,22 @@ def get_all_groups_and_group_members_with_access(
session: The database session to use.

Returns:
A list of tuples containing the editor ID, the group or user ID, and the most permissive read and write
permissions. This means that if a user has write=true but inherits write=false, their write will remain true.
Likewise, if a user has write=false and inherits write=true, their write will become true. This tuple will
appear in the form "(editor_id, uid, read, write)". Editors with inherited permissions have their editor_ID set
to None.
A list of tuples containing the editor ID, the group or user ID, and the most permissive read, write, and execute permissions.
Editors with inherited permissions have their ID set to None.
"""

# Begin constructing the top query, starting by selecting editors of the required type (doc or board)
topq = session.query(
editor_type.id, editor_type.uid, editor_type.read, editor_type.write
).select_from(editor_type)
if editor_type == DataDocEditor:
topq = session.query(
editor_type.id,
editor_type.uid,
editor_type.read,
editor_type.write,
editor_type.execute,
).select_from(editor_type)
elif editor_type == BoardEditor:
# BoardEditor doesn't have execute permission, so we use False as default
topq = session.query(
editor_type.id, editor_type.uid, editor_type.read, editor_type.write
).select_from(editor_type)

# Filter by the doc or board ID to get the editors for the specific doc or board
if editor_type == DataDocEditor:
Expand All @@ -43,32 +48,49 @@ def get_all_groups_and_group_members_with_access(

topq = topq.cte("cte", recursive=True)

# This bottom query determines if the user is a group or a user, and then selects the group members
bottomq = (
select([None, UserGroupMember.uid, topq.c.read, topq.c.write])
.select_from(topq)
.join(User, topq.c.uid == User.id)
.join(UserGroupMember, UserGroupMember.gid == User.id)
.filter(User.is_group)
)
if editor_type == DataDocEditor:
bottomq = (
select(
[None, UserGroupMember.uid, topq.c.read, topq.c.write, topq.c.execute]
)
.select_from(topq)
.join(User, topq.c.uid == User.id)
.join(UserGroupMember, UserGroupMember.gid == User.id)
.filter(User.is_group)
)
elif editor_type == BoardEditor:
bottomq = (
select([None, UserGroupMember.uid, topq.c.read, topq.c.write])
.select_from(topq)
.join(User, topq.c.uid == User.id)
.join(UserGroupMember, UserGroupMember.gid == User.id)
.filter(User.is_group)
)

# This is then applied recursively to the top query
recursive_q = topq.union(bottomq)

editors = recursive_q.alias()

q = select(
[
func.max(editors.c.id),
editors.c.uid,
func.max(
editors.c.read.cast(Integer)
), # Get the most permissive read permissions
func.max(
editors.c.write.cast(Integer)
), # Get the most permissive write permissions
]
).group_by(editors.c.uid)
if editor_type == DataDocEditor:
q = select(
[
func.max(editors.c.id),
editors.c.uid,
func.max(editors.c.read),
func.max(editors.c.write),
func.max(editors.c.execute),
]
).group_by(editors.c.uid)
elif editor_type == BoardEditor:
q = select(
[
func.max(editors.c.id),
editors.c.uid,
func.max(editors.c.read),
func.max(editors.c.write),
]
).group_by(editors.c.uid)

# Optionally filter by uid to get only the permissions for a specific user
if uid is not None:
Expand Down Expand Up @@ -113,7 +135,10 @@ def user_has_permission(

# Check the user's direct permissions
if permission_level == BoardDataDocPermission.READ:
if editor is not None and (editor.write or editor.read):
if editor is not None and (editor.write or editor.execute or editor.read):
return True
elif permission_level == BoardDataDocPermission.EXECUTE:
if editor is not None and (editor.write or editor.execute):
return True
elif permission_level == BoardDataDocPermission.WRITE:
if editor is not None and editor.write:
Expand All @@ -132,6 +157,10 @@ def user_has_permission(

if permission_level == BoardDataDocPermission.READ:
return True
elif permission_level == BoardDataDocPermission.EXECUTE:
# Check if the editor's execute or write privileges are true
if inherited_editors[0][3] or inherited_editors[0][4]: # write or execute
return True
elif permission_level == BoardDataDocPermission.WRITE:
# Check if the editor's write privileges are true
if inherited_editors[0][3]:
Expand Down
2 changes: 2 additions & 0 deletions querybook/server/models/datadoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class DataDocEditor(Base):

read = sql.Column(sql.Boolean, default=False, nullable=False)
write = sql.Column(sql.Boolean, default=False, nullable=False)
execute = sql.Column(sql.Boolean, default=False, nullable=False)

user = relationship("User", uselist=False)

Expand All @@ -192,6 +193,7 @@ def to_dict(self):
"uid": self.uid,
"read": self.read,
"write": self.write,
"execute": self.execute,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export const AccessRequestPermissionPicker: React.FunctionComponent<
<MenuItem onClick={() => setPermission(Permission.CAN_READ)}>
read only
</MenuItem>
<MenuItem onClick={() => setPermission(Permission.CAN_EXECUTE)}>
execute
</MenuItem>
<MenuItem onClick={() => setPermission(Permission.CAN_WRITE)}>
edit
</MenuItem>
Expand Down
Loading
Loading