Skip to content

Instantly share code, notes, and snippets.

@lyzadanger
Last active January 24, 2018 22:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lyzadanger/b050ef1ee529ab985b63ea4fa13cfd8d to your computer and use it in GitHub Desktop.
Save lyzadanger/b050ef1ee529ab985b63ea4fa13cfd8d to your computer and use it in GitHub Desktop.
Groups as the h application code currently sees them

What does “groups” mean to the application right now?

Activity

Acitivity’s query module has some interaction with groups in constructing and executing queries for activity pages.

API

Profile

The GET profile endpoint returns an object with a groups attribute—an Array of objects with:

        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "public": {
            "type": "boolean"
          }
        }

Annotation

Annotations have a group property, a string. Note that I believe this to be the groupid, not the name of the group.

Groups

DELETE groups/{id}/members/{user}

From the docs:

The user to remove from the group. For now, only the value me is supported, representing the currently-authenticated user.

Auth

Auth’s util module has a method groupfinder that is used to build a list of principals for the user at hand—a list that includes possible privileged roles (admin, staff) as well as group memberships and authority associations.

NB: This groupfinder is completely distinct from services/groupfinder.py AFAICT. Watch out for the naming confusion.

The role module defines Admin and Staff roles; this module uses the term group but I imply that this use of group is group as in permission/file group, not h’s specific meaning of group.

I don’t yet have a full picture of the how all of the pieces work together in pyramid for access, authentication and authorization of (e.g. Resources, Model-defined permissions, Auth module bits, like TOKEN_POLICY, etc. all seem to be involved), but I can see in general that there is access control going on :).

CLI

There is an —add-publisher-group command supported in the CLI (see cli/groups.py). It relies on the group service’s create.

DB

The db’s __init__ has a method _maybe_create_world_group which checks for the existence of the global Public group and creates it if it isn’t there.

Emails

flag_notification.py for sending notifications to group moderators when an annotation in that group is flagged.

Formatters

annotation_hidden.py handles the formatting of hidden annotations and leaves them visible to users who are moderators of that annotation’s associated group. It contains a method _current_user_is_moderator, but that method relies on an external moderator_check passed to the object’s constructor. It sort of gets this from services/annotation_json_presentation.py —it boils down to, I think, request.has_permission(‘admin’, group) in the end.

annotation_moderation.py also relies on an external check to be passed to it—in this case, a param has_permission, which I assume is, once again, request.has_permission. It performs the same check (‘admin’ permission on group).

Groups

There’s not as much to the actual groups module as one might think. It has two main concerns:

  • registering (adding) a search filter GroupAuthFilter which will filter out groups that the request.user isn’t qualified to see from search results. This uses the group service’s groupids_readable_by method to get a list of, well, groupids readable by the user.
  • defining a schema and validation for the add-group form (user private groups in this context), presumably used by the associated view for that.

Models

  • annotation.py: Sets the default for its groupid to __world__ (i.e. the global Public group)
  • user.py: It should be pointed out that there is no indication within the User model that there is any sort of groups relationship—this is entirely handled by an SQLAlchemy backref in group.py
  • group.py: The main banana for the definition of a group, of course. Defines an enum set of “permissions” (JoinableBy, ReadableBy, WriteableBy) and builds an __acl__ (access control list) that defines permissions for the group context in the pyramid app (this is where request.has_permission gets its rules from).

Interfaces

IGroupService Interface

Implemented by services/groupfinder.py, defines an interface for retrieving a group by its publicid. See “Services”

NIPSA

I confess, I don’t understand the NIPSA feature (beyond it standing for “not-in-public-site-areas”) but it looks like the NIPSA flag will apply to groups created by a NIPSA’d user? (see nipsa/search.py).

Panels

panels/navbar.py defines the context for the navbar that is “displayed at the top of most pages”. It generates an ordered list of user groups (via request.user.groups) to populate the groups menu, and also “groups suggestions” (I’m not sure yet where those manifest, though I can see a reference in the navbar template file at templates/panels/navbar.html.jinja2).

There is also a small reference to groups in a “back to group page” link in back_link.py here.

Presenters

annotation_searchindex.py has a single reference to an annotation’s group (groupid) field.

annotation_json.py also has that group field, but also “Converts our simple internal annotation storage format into the legacy complex permissions dict format that is still used in some places” via:

principals = security.principals_allowed_by_permission(
                    self.annotation_resource, 'read')

Resources

The application defines an AnnotationResource, which includes a group property. It generates an ACL (access-control list) for a given annotation resource based in part on the principal’s permissions vis-a-vis the annotation’s group. See “Model”.

Schemas

annotation.py is a JSON schema for the annotation API. There is a group property defined (a string; takes a group pubid). There’s also:

            'permissions': {
                'title': 'Permissions',
                'description': 'Annotation action access control list',
                'type': 'object',
                'patternProperties': {
                    '^(admin|delete|read|update)$': {
                        'type': 'array',
                        'items': {
                            'type': 'string',
                            'pattern': '^(acct:|group:).+$',
                        },
                    }
                },

Perhaps that’s the (legacy?) permissions dictionary that is referenced in presenters/annotation_json.

n.b.: An aside: the annotation schema is the only JSON schema in schemas. There is no schema here for groups or other resources.

Search

config has an ANNOTATION_MAPPING dictionary that contains a group property (string).

core applies the group search filter defined in query — it also presumably applies the GroupAuthFilter defined in the Groups module.

parser defines valid search params/keys, including group

query defines a GroupFilter

Services

group. This provides the current “group type” definition—“private” and “publisher”—via GROUP_ACCESS_FLAGS (the model is unaware of meaningful groupings of access settings). It also provides methods for creating a group, joining a group and leaving a group and some utility methods for obtaining a full list of groupids “readable by” a user and a full list of groupids “created” by a user.

authority_group provides a service for listing all world-readable groups in a given authority.

groupfinder implements the IGroupService interface. It retrieves a group based on a string pubid. NB: Although the interface is called IGroupService, the implemented service is groupfindernot the group service which is noted above.

Other services with some groups involvement include:

  • auth_ticket has a method, groups, which returns—watch out—a list of principals for the user. That includes the groups to which they belong, but also includes admin and staff roles (if applicable) and likely does not include the global public group. In any case, this is “groups” in a different context here. See also Auth/util
  • annotation_stats has the ability to filter annotation stats by group. It has a notion of “public”, “group” or “private” hard-coded. It queries the Annotation model filtered by group.
  • annotation_json_presentation: At the end of the day, this is using request.has_permission to check for group access.
  • delete_user: Deletes a user, removes the user as member from any group, and deletes any (private) groups created by the user if they don’t have posts from any other users.

Session

The GET Profile API endpoint makes use of the session module to populate the profile object, and, as part of that, the groups property on the profile object that is returned in the response.

That API endpoint is the only consumer of session.profile in the app.

Methods within the module are responsible for generating the proper list of groups visible to the profile/user at hand (currently: always only the logged-in user or a non-auth’d user)—it is an important piece here. Relevant methods are _current_groups and _user_groups; the session module is also currently responsible for the sorting of returned groups and the “model” of the returned groups (i.e. attrs).

Storage

storage.py is a module that provides business logic for the persistence of annotations, specifically (other resources don’t use this design pattern).

There are several unique things about storage module’s interaction with groups:

  • It enforces that annotation replies are in the same group as their parent annotations
  • It makes use of the groupfinder service to retrieve Group models by groupid
  • It checks to make sure that the current user has write permissions for the group in question. See “Model”.

Streamer

This, the websocket/realtime API:

  • Uses the groupfinder service to retrieve groups from pubids
  • Has a bit of a different tack on group permissions:
def _authorized_to_read(effective_principals, permissions):
    """Return True if the passed request is authorized to read the annotation.

    If the annotation belongs to a private group, this will return False if the
    authenticated user isn't a member of that group.
    """
    read_permissions = permissions.get('read', [])
    read_principals = translate_annotation_principals(read_permissions)
    if set(read_principals).intersection(effective_principals):
        return True
    return False

Views

  • api_moderation: Uses request.has_permission to check for group admin access
  • api_flags: uses the groupfinder service to retrieve an annotation’s group; sends email to group.creator (note: this differs from other moderation-flag related code in that it doesn’t check for admin access—it checks for the group’s creator)
  • activity.py: has 72 matches for group, that is, groups figure in significantly (for activity/searches filtered by group, primarily). Note that it uses has_permission to check for current user’s read access for a group as well as “joinable” access.
  • groups.py: View for creating a private group. Relies on the group service. Note that if group.joinable_by is None and the user is not logged in, it raises a 404. This may not be quite the right behavior for all group types in the future. (Also: this feels like controller-level logic, not view-level?)
  • api.py: Note that the api view also contains API endpoints concerning annotations (whereas other API resources are broken out into api_* modules). Relies on GroupFinder service (IGroup interface implementor).
  • admin_groups.py: The groups admin page shows all groups defined in the service. That means the current groups admin page on h ’s production service has, currently, 1006 pages of groups. They are not searchable. This won’t scale, of course.
  • api_groups.py: Endpoint for deleting a group membership (removing a user from a group). Currently only supported for currently-logged-in-user.
@seanh
Copy link

seanh commented Jan 24, 2018

It used to be that we had only the Public group (anyone can read, anyone who is logged in can write) and private groups (only members can read or write, anyone with the link can join and become a member). I guess that's where the simple "public": {"type": "boolean"} of the GET profile endpoint came from.

Since then things have become more complicated. We've added third-party accounts and third-party groups, so the anyone can read, anyone who is logged in can write for the Public group is now anyone can read, anyone who is logged in to a @hypothes.is account can write, and there can be other groups such as eLife's group where it's anyone can read, anyone who is logged in to a @elifesciences.org account can write.

And of course we're now talking about implementing open groups and restricted groups, which each have their own rules.

In light of all this the simple "public": {"type": "boolean"} of the GET profile endpoint may not make any sense anymore. I guess a public: true|false boolean is still one of the dimensions along which groups can vary, where public: true means anyone can read the group and public: false means only members can read the group.

I think this public: true|false boolean pre-dates the more fine grained ReadableBy, JoinableBy, WritableBy fields now in the groups model.

@seanh
Copy link

seanh commented Jan 24, 2018

One thing to know about group IDs is that with private groups, currently, if you can get the group ID by any means then you can construct the group's URL (https://hypothes.is/groups/<ID>) and use that page to join the group and be able to read and write it. So the IDs or private groups are meant to be kept secret among the members of the group. The ID is also the only thing that uniquely identifies the group (group names aren't unique).

@seanh
Copy link

seanh commented Jan 24, 2018

The groupfinder thing in auth is a Pyramid thing, that's a different kind of "group" from Hypothesis groups.

@seanh
Copy link

seanh commented Jan 24, 2018

The --add-publisher-group CLI command is from when we were calling the third-party group that we implemented for eLife a "publisher group". That publisher group feature is deeply coupled to third-party account authorities and login integration. But the term "publisher group" is now being used (for example in documents about future group features, etc) to mean something quite different, along the lines of "Any group that's promoted or featured by the publisher, that appears when the client is launched on the publisher's website and shows at the top of the client's groups menu looking somehow more authoritative than the other groups." So this new concept of a publisher group doesn't necessarily have anything to do with / is orthogonal to third-party accounts integration.

@seanh
Copy link

seanh commented Jan 24, 2018

The NIPSA feature is just a simple way for us (Hyothesis admins) to block unwanted content. IIRC the way it works is that you NIPSA a user and, once NIPSAd, all of that user's annotations past and future, no matter what group the annotations are in, are hidden from all other users.

@lyzadanger
Copy link
Author

@seanh

The groupfinder thing in auth is a Pyramid thing, that's a different kind of "group" from Hypothesis groups.

Not pertinent to tasks at hand so table the hell out of this, but that naming is suuuuper confusing (not on Pyramid's part but our own), cf:

  • Group service (truly about app groups)
  • GroupFinder service (for retrieval of app Groups models but named the same thing as p's built-in auth stuff)
  • groupfinder (that's auth stuff, via Pyramid, nothing to do with "our" Groups)
  • IGroupInterface which is actually implemented by the GroupFinder service, not the Group service as one might expect based on the interface name

My vote: over time, rename anything that is called GroupFinder within our app code, maybe? Or is there supposed to be a connection between the two (app logic and auth in web framework)?

@lyzadanger
Copy link
Author

@seanh

One thing to know about group IDs is that with private groups, currently, if you can get the group ID by any means then you can construct the group's URL (https://hypothes.is/groups/) and use that page to join the group and be able to read and write it. So the IDs or private groups are meant to be kept secret among the members of the group. The ID is also the only thing that uniquely identifies the group (group names aren't unique).

V. glad you told me that; important to know for later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment