WIP - scopes refactor for multiple dataset owners #456
dataset-scopes into main
cf6c42710d
9420267920
@sneakers-the-rat so my main questions are:
- current implementation has 3 scopes for datasets:
edit,permissions(for granting/revoking permissions for other users),delete... any others I should add to the mix? - current implementation replaces
dataset.account == current_accountwithcurrent_account.has_scopefor editable/removable_by ... do we want to retain the former as well? in other words, do we want to allow a dataset's creator to have their scopes revoked? (migration creates scopes for dataset creators, so nobody will lose edit/delete access to datasets they created if we keep it as is) - related to 2, in those editable/removable_by checks, I'm looking for the appropriate scope whenever
hasattr(self, "dataset_id"), which provides a bit of hierarchy for models which havedataset_idas a foreign key (dataset parts, uploads, etc), so, for example, users with theeditscope for a dataset can also edit that dataset's uploads. for uploads though, I did retain theself.account == current_accountso uploaders can still edit/remove their uploads for datasets that they don't have scopes for. not really a question, but wanna make sure that all makes sense.
other than that, the gist is...
- a new
AccountDatasetScopeLinkstable, pmuchAccountScopeLinksbut with adataset_idforeign key... i was originally thinking of turningAccountScopeLinksinto a more general purposeScopeLinkswith an integer primary key and a unique constraint across account/dataset/upload ID foreign keys, which would each be nullable. but after getting further along it seemed like it'd be a bit over-engineering for a few reasons. and the main thing we're looking for here is multiple dataset owners, so went with this for simplicity. but might be worth doing that if we want scopes to be applied to more models, and it wouldn't be too much to refactor and maybe do part of it as a mixin. - a new "collaborators" field in the dataset form, with tokens that look similar to the admin page's account scope rows. but unlike the account scopes which make database changes whenever you click on the scope buttons, changes only happen when you submit the form. but I also included a couple API routes for granting/revoking individual dataset scopes to users.
- regular users (non-review/admin/root) can only modify permissions that they've already been granted, and can't modify their own permissions (e.g., this screenshot was taken as the
randouser)
Screenshot From 2025年09月27日 15-49-47
other things in this pr that aren't related to multiple dataset owners/scopes stuff, but I included anyways:
- updated tags autocomplete to exclude already selected tags... also prevented adding tokens with <2 chars on the frontend, since that's the min for the model
- made it so all form fields have the magenta border, which was only showing for a few fields
- added a block to
pyproject.tomlso my editor won't complain about not finding packages and whatnot - datasets without a description were displaying "None" because that was the value of the field, so I changed that to display "No description provided"
gotta write tests, but otherwise feel free to review/request changes!
making a couple comments just reading your comment before reading the code since i have a few minutes before monsterdon starts:
permissions (for granting/revoking permissions for other users)
this seems like it could be a little tricky, esp. as an othogonal scope to adding/removing other users from the ownership set (e.g. could i add someone as an owner and then not be able to give them any permissions?). ability to edit permissions is currently done by admin scope which i sort of think is different in kind than other permissions, which are what kind of things you can do, vs. what kind of account you are, so not my favorite, but it's also sort of important for permissions to be able to designate someone as the "primary" on something (esp. while the edit histories are on the fritz so we can't just do nondestructive rollbacks). not rly sure, will think more after reading code.
do we want to allow a dataset's creator to have their scopes revoked?
no i don't think so, unless they give it away or have it revoked by a moderation action
I'm looking for the appropriate scope whenever hasattr(self, "dataset_id")
i'll take a closer look when i read the code, but from the description i'd be a little concerned about having this in the root method implementation, since the odds we forget it's in there and have some other model that has dataset_id in it and give ourselves off target effects seems somewhat high, but lemme read first.
turning AccountScopeLinks into a more general purpose ScopeLinks
I think we'll eventually need to get here, but we may want to move this to graph DB land where this would be easier to do, and to generalize this in sql land should be relatively straightforward to convert the special AccountDatasetScopeLinks table to a more general form. totally reasonable call.
unlike the account scopes which make database changes whenever you click on the scope buttons, changes only happen when you submit the form
makes perfect sense
made it so all form fields have the magenta border,
this was intended to indicate required/not required (purple border == required), but let me see the changes
otherwise looks good! love the frontend screenshot. will give an actual review l8r
@sneakers-the-rat wrote in #456 (comment):
permissions (for granting/revoking permissions for other users)
this seems like it could be a little tricky, esp. as an othogonal scope to adding/removing other users from the ownership set (e.g. could i add someone as an owner and then not be able to give them any permissions?). ability to edit permissions is currently done by
adminscope which i sort of think is different in kind than other permissions, which are what kind of things you can do, vs. what kind of account you are, so not my favorite, but it's also sort of important for permissions to be able to designate someone as the "primary" on something (esp. while the edit histories are on the fritz so we can't just do nondestructive rollbacks). not rly sure, will think more after reading code.
ah, so that's a thing i didn't mention. I put the existing scopes (admin, upload, etc) into a new string enum called AccountScopes and dataset scopes onto one called ItemScopes. the Scopes enum that existed before still exists and combines both, but that's pmuch just used for Scopes table stuff now. for displaying scopes on the admin page, it pulls from AccountScopes, and from ItemScopes in the dataset form. this also is reflected in other places like, for example, the RequireValidScope route dep is now RequireValidAccountScope and RequireValidItemScope for the respective grant/revoke account/dataset scopes routes.
this does complicate the way we think about scopes, but it seemed to me that the functionalities we want to provide for multiple dataset owners (edit, delete, etc) don't map onto existing instance-wide account scopes, and vice versa. and even if we want to just use a single scope that grants full ownership to each "collaborator", trying to use existing scopes for both purposes (e.g., { scope: "admin", dataset: {...}, account: {...} }) is risky wrt future work both because it muddies the water for that scope, and because it opens the door to forgetting about the dataset use case for the scope and introducing bugs where users get abilities they shouldn't have. so separation of concerns wrt scope values seems like a good idea here to me.
for clarity, given the above, you still have to be an admin to modify instance-wide account scopes (upload, submit, etc), but any user can modify users' scopes for a given dataset if they have the permissions scope for that dataset.
also don't think i said that the permissions scope includes the edit scope, similar to how the review/admin/root scopes include the upload and submit scopes. because the "collaborators" scope field is in the dataset form and a permissions scope without an edit scope would be useless.
i also think granular scopes for datasets will be useful for future work, as opposed to granting full ownership to each collaborator. e.g., since we want to enable hidden/dark datasets, we could add a "visible" scope that would allow certain users to see and upload to a dataset without being able to edit or delete it.
do we want to allow a dataset's creator to have their scopes revoked?
no i don't think so, unless they give it away or have it revoked by a moderation action
righto, easy to add that back. the current implementation basically assumes that there's no hierarchy among dataset owners (ie, anyone with the permissions scope can revoke any other user's scopes for that dataset). which yeah, could enable bad actors who gets the right permissions to mess up a dataset, esp without being able to do rollbacks, but the idea is also that it's trusted people (like people affiliated with an org) getting permissions, right? i think a teams feature would also be a useful future feature, so users could create teams and grant permissions to the entire team, and removing someone from the team would result in loss of privs for datasets that the team has scopes for.
I'm looking for the appropriate scope whenever hasattr(self, "dataset_id")
i'll take a closer look when i read the code, but from the description i'd be a little concerned about having this in the root method implementation, since the odds we forget it's in there and have some other model that has
dataset_idin it and give ourselves off target effects seems somewhat high, but lemme read first.
def something i was concerned about too... could just override those methods for the dataset class to avoid changing the root method... and could still achieve the hierarchy i described (where dataset owners can edit the dataset's uploads), if desired, by also overriding the methods in classes where we want that. i'll go ahead and do that since it'd be more future proof.
turning AccountScopeLinks into a more general purpose ScopeLinks
I think we'll eventually need to get here, but we may want to move this to graph DB land where this would be easier to do, and to generalize this in sql land should be relatively straightforward to convert the special
AccountDatasetScopeLinkstable to a more general form. totally reasonable call.
ya know what, i'll go ahead and try out the more general approach and see if my concerns were even valid
made it so all form fields have the magenta border,
this was intended to indicate required/not required (purple border == required), but let me see the changes
ah ok, i actually thought that was the case at first but made the change bc i found a discrepancy between required fields and fields with magenta borders at one point. so i'll remove that change and see if i can i replicate what i'd seen (was a while ago, forgot exactly what it was) in case there's a different fix needed to bring the borders and required into alignment
book-length response, sorryyyyy 😜
hell yeah. like i said above, this is a ton of work, thank you for hanging in there and finishing this.
Some comments, some are stylistic, some are more substantive, hopefully the content of the comment makes it clear which is which. i think main thing that's needed is tests, and those should probably include some playwright tests since the frontend interaction part is pretty sophisticated for sciop. then we'll want to make sure we cover all the basic stuff like adding a collaborator, adding/removing scopes, and then testing that an account with given scopes can do the things they should be able to do and vice versa re: accounts without scopes.
we have talked about the macro-level design stuff like wanting to have a more general way to apply functionality to all item types rather than having to do them one by one in chat, but i think this is good, and datasets are a bit of a special beast as an abstract reference to an 'item in the world' vs. uploads which are more explicitly 'items created by the account in question'
@ -233,6 +234,38 @@ def require_visible_dataset(dataset: RequireDataset, current_account: CurrentAcc
RequireVisibleDataset = Annotated[Dataset, Depends(require_visible_dataset)]
def require_scopable_dataset(
docstring here plz, not immediately clear to me what this is requiring
@ -121,3 +122,3 @@
dataset_slug: str,
dataset_patch: Annotated[DatasetUpdate, Body()],
dataset: RequireVisibleDataset,
dataset: RequireScopableDataset,
seems like this might have overlapping responsibility with the RequireEditableBy dependency?
@ -344,0 +405,4 @@
account: RequireAccount,
session: SessionDep,
):
if not current_account.get_scope("review"):
maybe we need a method like can_grant_scope in the moderable mixin? some way of encapsulating this check for whether the action can be taken so we don't have to repeat it
oh yeah, i like that. will do!
@ -344,0 +420,4 @@
if username == current_account.username:
raise HTTPException(403, "You cannot grant permissions to yourself.")
if scope := next(
excellent use of the language features
@ -82,0 +87,4 @@
if current_account:
scopes.insert(
0,
ItemScopesRead(
usually these *Read models are used to display data publicly, while the *Create models specify the fields needed when creating an item.
reading further, ya i think this and the other places where there are AccountDatasetScopeLink <-> ItemScopesRead transformations should be methods on ItemScopesRead and that would clean things up a bit
@ -508,0 +539,4 @@
scope_links = [e for e in existing_scopes if (e.account.username, e.scope.value) in new]
revoked_scopes = [e for e in existing_scopes if e not in scope_links]
for scope in revoked_scopes:
session.delete(scope)
I think this function might be doing too much - i see how it's used below, when updating scopes, but the assignment of a list of scopes should handle deleting the items not in the new collection. so if this just returns the links that should exist, subsetting from the ones that already exist, then i think this would be potentially less surprising than the scopes to delete already having been deleted by the time we return from this.
the ListlikeMixin.get_items method might be what you're looking for here
@ -18,3 +20,3 @@
@autocomplete_router.get("/tags")
@jinja.hx("partials/autocomplete-options.html")
async def tags(tags: str, session: SessionDep) -> list[str]:
async def tags(
nice, love this
@ -52,0 +83,4 @@
):
acct = session.exec(
select(Account).where(
Account.username == account_query.strip(), Account != current_account
also Account.is_suspended == False
@ -0,0 +93,4 @@
op.bulk_insert(Scopes, new_scopes)
conn.execute(
sa.text(
"""
one extra space here re: alignment
@ -0,0 +94,4 @@
conn.execute(
sa.text(
"""
INSERT INTO account_dataset_scope_links (
dang i know nothing about SQL, this looks like this works but i have never used INSERT INTO before
heh, the orm we used at my last job sucked ass and maintenance/refactors rarely got approved, so i had to hand-write way more sql than i would've liked
@ -0,0 +113,4 @@
# ### end Alembic commands ###
def downgrade() -> None:
would also need to delete the new scopes, right?
@ -44,4 +32,3 @@
model_config = ConfigDict(ignored_types=(hybrid_method,))
@hybrid_method
def has_scope(self, *args: str | Scopes) -> bool:
can't make multiple comments on the same line, so commenting one line above:
elsewhere, throughout the program, we don't usually use the row ids as identifiers, and even though this is an internal method, we may want to use the dataset slug rather than the id for consistency. it should be indexed so perf should be similar. not a strong opinion.
@ -45,3 +33,3 @@
@hybrid_method
def has_scope(self, *args: str | Scopes) -> bool:
def has_scope(self, *args: str | Scopes, dataset_id: Optional[int] = None) -> bool:
i think we probably want to make the changes here into a separate method.
so now we have two notions of scopes: scopes for accounts that determine what an account can do globally, and scopes for items x account that determine what an account can do for a specific item. those seem separable enough to me that i think we should probably have a separate has_item_scope() method.
maybe the logic should live on the items not the account? like in the circumstances where we are checking whether an account can do something with an item we should have both the account and the item, so if the method lives on the item then we don't need switching behavior like we would in the account.
let me finish reading and think more about what would be good here
makes sense to me. i'd actually considered that after i'd already started modifying those methods, and kinda just flipped a coin in my head about it.
@ -81,0 +68,4 @@
has_scopes = [
scope.scope for scope in self.dataset_scopes if scope.dataset_id == dataset_id
]
if "permissions" in has_scopes:
this seems a touch implicit to me in the same way the existing implementation does, where if one permissions scope implies another then we should probably directly model that. is there ever a time when someone would have permissions without edit? should there be?
i def agree, and no, permissions should always include edit because otherwise permissions is useless. i'm curious how you'd model that hierarchy tho, is there another place in the codebase i can peek at to see the pattern you've got in mind?
@ -95,0 +90,4 @@
if dataset_id:
scopes = list(args)
if "permissions" in args and "edit" not in args:
so if i am checking if someone has permissions, if that account has edit but not permissions, wouldn't i get a false positive here? this method works a bit differently than the non-hybrid has_scope method, and i think it should probably work the other way -
if "edit" in args and "permissions" not in args:
scopes.append("permissions")
so that if we are checking if someone has edit permissions, but they only have permissions permissions, they implicitly are granted edit permissions
ope, you're totally right, my bad!
@ -254,6 +255,7 @@ class Dataset(DatasetBase, TableMixin, SearchableMixin, EditableMixin, SortMixin
)
account_id: Optional[int] = Field(default=None, foreign_key="accounts.account_id", index=True)
account: Optional["Account"] = Relationship(back_populates="datasets")
account_scopes: list["AccountDatasetScopeLink"] = Relationship(back_populates="dataset")
so now we have both an account and an account_scopes field - what is the account field for now? is that how we indicate a creator of a dataset, even if they are now one of several collaborators?
that's something i was kinda wondering. you said, either above or in signal, that we should display collaborators on the dataset page. so i think account/account_id aren't really doing anything once i do that + make the self page use account_scopes as well
only thing is the change wouldn't be undoable if we ever revert the migration. but i'm cool with that if you are!
@ -325,6 +337,17 @@ class DatasetCreate(DatasetBase):
min_length=1,
max_length=512,
)
account_scopes: Optional[list["ItemScopesRead"]] = Field(
i think we need this on DatasetRead too?
@ -328,0 +345,4 @@
Hover over permission buttons to see their descriptions.
""",
schema_extra={"json_schema_extra": {"input_type": InputType.account_scopes}},
max_length=512,
the party dataset
to have more than 512 collaborators we'll have to install another fire exit
@ -355,2 +378,4 @@
for ex in dataset.external_identifiers
],
"account_scopes": [
ItemScopesRead(
seems like we should be using a *Create model for this, just to keep things tidy. (i sort of hate this multiple models for the same thing design, but it is what fits the tools, and there have been plenty of times where we want separate models for each of these contexts)
@ -105,3 +105,3 @@
if account is None:
return False
return self.account == account or account.has_scope("review")
return (
i think this should go in an override of editable_by on the Dataset class - since this only applies to Datasets, let's minimize chance for unexpected behavior on the other items until we do a more general solution.
re: your comment about this extending to dataset parts, should be possible to write it only once by being a separate function that's called by the overridden method, or by assigning to the class object directly, idk i'll mess around with that
@ -115,0 +121,4 @@
or_stmts = [account.has_scope("review") == True]
if hasattr(cls, "dataset_id"):
same thing as above, move to override on Dataset class
@ -62,3 +62,3 @@
elif self.is_removed:
return False
elif hasattr(self, "account") and self.account == account:
elif hasattr(self, "dataset_id") and account.has_scope(
same as above, probably in an override on Dataset - the notion of "account" remains true for other item types.
edit: rather than making a comment on each change to mixin methods, i think each of these are overrides. i think of these mixins sort of like a rust trait or a python protocol - these methods say "things that are moderable have some method visible_to that is used to check visibility, and those implementations might vary. and this implementation is just the default one unless they have a more specific criterion for visibility.
@ -0,0 +67,4 @@
scope: Scopes = Field(unique=True)
class ItemScopesRead(BaseModel):
you might consider making some of the nested list comprehensions and whatnot done elsewhere to create these as classmethod creators here - like
@classmethod
def from_links(cls, links: list[AccountDatasetScopeLink]) -> list[ItemScopesRead]: ...
def to_links() -> list[AccountDatasetScopeLink]: ...
@ -0,0 +68,4 @@
class ItemScopesRead(BaseModel):
"""Aggregated-by-account scopes object returned from API methods"""
I think my comments elsewhere being uneasy about the *Read *Create are just from not having reached this point yet and seeing that it's not a table model, but a convenience container. since there isn't really a notion of *Read (which is mostly used to censor or transform the database form of an object) vs *Create here, you might want to just name this ItemScopes (though i would expect such a class to have an item field, that's just a documentation thing - this is an object used by some item, so the item is implicit whenever this is used)
@ -0,0 +77,4 @@
class ItemScopesAction(BaseModel):
"""Action object for handling account scope form events"""
action: Literal["load", "add account", "add scope", "remove scope"]
can we make these names underscored like add_account and etc? i am always a bit wary with adding programmatically meaningful tokens as strings with spaces.
@ -7,3 +7,3 @@
padding: 1em;
color: var(--color-text);
border: 1px solid var(--color-primary);
border: 1px solid var(--color-primary) !important;
not having purple border around all elements is intentional - modified by presence of .optional and disabled
@ -96,3 +96,3 @@
}
&:disabled {
&:disabled, &.disabled {
why disabled as a class as well as the property?
@ -177,0 +182,4 @@
&.scopes-form {
margin-bottom: 0em;
&.has-entries {
you can just do this with :has(div) or :has(.class) or whatever ya want instead of needing to compute this in the template :)
@ -58,6 +58,7 @@
}
}
.account-row,
indentation
think this is correct actually, it's nested within other blocks. unless I'm looking at the wrong thing
@ -105,3 +105,3 @@
// Token input field
function addToken(e){
if (e.key !== "Enter"){ return }
if (e.key !== "Enter" || e.target.value.length < 2){ return }
nice, thanks
@ -1,8 +1,61 @@
{% macro account_row(dataset, current_account, config) %}
{% macro account_row(account, items=None, current_account=None, current_account_scopes=[], edit=False, idx=None) %}
we may want to make this name more specific, but otherwise this is nice.
@ -3,0 +22,4 @@
or (scope_name != "edit" and scope_name not in current_account_scopes)
)
) %}
{% if scope_name in account.scopes or (scope_name == "edit" and "permissions" in account.scopes) %}
could do the same thing you do above and do {% set checked=...%} and avoid duplicating the <a...>
@ -164,12 +166,47 @@
hx-get="/autocomplete/{{ field_name }}"
hx-target="#{{ parent_id }}-{{ field_name }}-autocomplete"
hx-trigger="input changed delay:250ms"
hx-include="#{{ parent_id }}-{{ field_name }}-tokens"
shouldn't be necessary, each of the token buttons contain a hidden input within the form
nah, it is. by default, hx-get only includes the value of its field, while most (all?) other methods include the entire form. in this hx-include I'm referencing the parent element which contains all of those hidden inputs, which is how they're being included in the request.
that said, I don't think we need the hx-params="*" on the line below. seems like it's not doing anything here. I think that might've gotten left in from when I was considering using a different variable name for the query string so I wouldn't have to do the query = tags[0] on the backend, but that ended up causing more problems than it was worth :P
@ -9,3 +9,3 @@
<h2>Scopes</h2>
{% for scope in account.scopes %}
{% for scope in account.scopes if scope.scope.value in types.AccountScopes.__members__.values() %}
i don't know if this is necessary, invalid scopes should never exist right?
@ -285,3 +286,2 @@
class Scopes(StrEnum):
"""Account Permissions"""
class AccountScopes(StrEnum):
the multiple definition of these makes me a bit uneasy, but i'm not sure of a better way to refer to subsets of an enum
yeah i went hunting for a way to do it and couldn't find anything that wasn't super hacky :\
actually, maybe annotations?
one more thing - do we display collaborator usernames on the dataset page? should probably do that.
sorry just saw your response after i submitted review. i think i responded to some things after having read the code in comments, but responding to more specific things here:
think a teams feature would also be a useful future feature, so users could create teams and grant permissions to the entire team, and removing someone from the team would result in loss of privs for datasets that the team has scopes for.
agreed that teams/groups should be the eventual form of this. i like having both, where i think teams/groups should basically be addressable as if it was a single account, so e.g. one could have one-off collaborators on some items in addition to it belonging to a group, etc.
i'll go ahead and try out the more general approach and see if my concerns were even valid
if you want to! this is already plenty, so if it's a substantial amount of work to do, then i wouldn't worry about it.
1bfbc1425c
36eb595be4
36eb595be4
98a8162d8b
@sneakers-the-rat getting back on this finally. writing tests still, but any idea why black is requesting reformatting in the failed lint check for the last commit? locally black is saying those files are good :P
Oh ya that got me too. Black 26 changed string formatting to make it more compact. I liked the new style so went with that rather than disabling/pinning the version in #481
Also changed the tests so they use the lockfile instead of the most recent versions of packages since this is "an application" after all. If you rebase/merge main and reinstall from the lockfile you should be good
ah, yeah shoulda thought to try that lol. thanks!
@sneakers-the-rat how do we want to handle "reported account"/report actions for multi-owner datasets?
this method is expecting the report target to have an account link if the target isn't an account itself. I removed the datasets.account_id field because it wasn't being used for anything after I switched the submitted by label on dataset pages to be maintained by and display everyone with scopes for the dataset.
I could add it back for reporting purposes, but the person who submitted the reported dataset might not be the person who violated some site rule if the dataset has multiple owners.
OTOH, if we do want to remove datasets.account_id, then for ReportAction.suspend_remove we have a choice to make - does it remove all datasets the account has scopes for even if some of the datasets have other owners? Do we just skip auto-removing datasets altogether? Do we only auto-remove datasets where the suspended account was the sole owner? For the last option, malicious users would be able to give dataset scopes to random users to prevent the datasets from being automatically removed, but maybe we could just display a table of the account's datasets that have other owners when the person handling the report clicks the "suspend remove" button so they can select which datasets to keep and which to remove.
very good questions:
-
- I think if datasets are truly multi-owner they should behave like it and be modeled like it, so yes i think we shouldn't have a separate field just for reporting purposes. I think the basic behavior we want is "if a dataset with multiple owners is reported and we want to take action against the account as well as the dataset, then moderators should be able to select which of the accounts action is being taken against"
-
- I think for removing, by default we should not remove datasets that are owned by multiple accounts when one of them is suspended. maybe only when all of the accounts are suspended. I like your suggestion to be able to select/deselect datasets to remove, let's go with that. would it be a lot of work to prefill that form with like "all the datasets that are solely owned by an account" already checked?
-
- It's a good point re: abuse and randomly adding other people as co-maintainers. In general i think we want to not have it be possible to people to add other people to things, so ideally that would be a request flow: either the current owner requests to add someone to a dataset and they approve/deny, or someone else who wants to become an owner can request it and the current maintainers can approve/deny. That might be a decent bit of work and this PR is already a ton of work, so we might want to come back to that in another PR.
that all makes sense to me!
re: prefilling forms with solely-owned datasets, that should be easy. will do!
and yeah, a request flow def sounds like a good idea... and would be good to already have implemented for other stuff like teams. I'll think on that a bit, but yeah, it might deserve its own PR if it should work for different resources like that.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.No due date set.
No dependencies set.
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?