Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Beamline Groups & Filtering #203

Merged
merged 127 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from 122 commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
0a4b002
add db options, add example for legacy routes
stufisher Jul 19, 2022
4b4be0f
call correct function
stufisher Jul 19, 2022
744939d
activity is also used to store `whos online` information, move to mor…
stufisher Jul 20, 2022
230028a
lint
stufisher Jul 20, 2022
24eed16
initial work on beamline grouping and filtering
stufisher Jul 21, 2022
431f4a8
apply to samples
stufisher Jul 21, 2022
efcc1fc
style
stufisher Jul 21, 2022
7033d35
make events query more descriptive
stufisher Aug 9, 2022
117b46f
add proposal and session resources, authorized by groups
stufisher Sep 14, 2022
fc41fb2
lint
stufisher Sep 14, 2022
e8f5d32
update sample
stufisher Sep 14, 2022
0ba399d
move legacy routes out of root path to avoid collisions
stufisher Sep 14, 2022
4d2a3b4
username -> login
stufisher Sep 14, 2022
524ec91
test groupings
stufisher Sep 14, 2022
23fb5d0
lint
stufisher Sep 14, 2022
0e96780
update tests for new route location
stufisher Sep 14, 2022
ce298f3
update legacy routes, move to separate folder
stufisher Sep 14, 2022
18270aa
bump ispyb-models
stufisher Sep 14, 2022
ab057b4
only return groups if beamlineGroups defined
stufisher Sep 14, 2022
5550b1e
groups could be empty
stufisher Sep 14, 2022
dc8d3f9
match beamlines and count with test data
stufisher Sep 14, 2022
cc53acb
match beamlines and count with test data
stufisher Sep 14, 2022
963bc62
ignore order
stufisher Sep 14, 2022
4d2c95e
make joins optional, auth labcontacts
stufisher Sep 16, 2022
eabed22
put back proposalId
stufisher Sep 16, 2022
518fb13
distinct
stufisher Sep 19, 2022
a14b4a4
style
stufisher Sep 19, 2022
e927e70
document some dc columns
stufisher Sep 19, 2022
9bc2880
warn about missing options
stufisher Sep 19, 2022
82e0aed
add subsamples, update contains_eager
stufisher Sep 19, 2022
457f9bb
remove debug
stufisher Sep 19, 2022
e2f343e
lint
stufisher Sep 19, 2022
2775448
add proteins, add ordering
stufisher Sep 20, 2022
84bdd94
remove componenttype for now
stufisher Sep 20, 2022
2d8a742
protein work
stufisher Sep 20, 2022
9c220dd
sort_order can be none
stufisher Sep 20, 2022
10271fe
add event status filter
stufisher Sep 20, 2022
00739bf
outerjoin...
stufisher Sep 20, 2022
5337d6b
add sample positions, sampleimages, queued status
stufisher Sep 21, 2022
606841c
add proteins router
stufisher Sep 21, 2022
41caf74
add attachments, split dc parts from events
stufisher Sep 22, 2022
b2ee5db
lint
stufisher Sep 22, 2022
b81fb89
lint
stufisher Sep 22, 2022
d842194
add uiGroup, set empty grouping on unset
stufisher Sep 22, 2022
9a77744
correct empty groupings
stufisher Sep 22, 2022
aa0660c
update contains_eager to non-legacy
stufisher Sep 22, 2022
dd41e3c
correct image loader
stufisher Sep 22, 2022
80e65cb
test
stufisher Sep 22, 2022
d8e9824
bump ispyb-models
stufisher Sep 22, 2022
9b5cdb6
consistent naming
stufisher Sep 23, 2022
9e479a3
syntax
stufisher Sep 23, 2022
14c7b31
add session filters
stufisher Sep 23, 2022
fc18d62
rough doc
stufisher Sep 23, 2022
f73a554
rough doc
stufisher Sep 23, 2022
e5bf84c
fix tests
stufisher Sep 23, 2022
f3cd540
doc
stufisher Sep 23, 2022
aeece36
update naming
stufisher Sep 23, 2022
be770de
uiGroups rename, return with current user
stufisher Sep 23, 2022
e6e5629
rename up classes to avoid collision
stufisher Sep 23, 2022
29a6985
wip
stufisher Sep 25, 2022
4a79dc1
wip
stufisher Sep 25, 2022
5dc7fdc
bump ispyb-models
stufisher Sep 25, 2022
da72767
add root mapping, remove trailing /
stufisher Sep 26, 2022
8556711
add path_map for sample images
stufisher Sep 26, 2022
c470fb2
correct uiGroup
stufisher Sep 26, 2022
4df250f
or permissions
stufisher Sep 26, 2022
fc78b5c
tidy schema
stufisher Sep 26, 2022
b85df82
doc, correct join
stufisher Sep 26, 2022
323911e
add eventType filter
stufisher Sep 26, 2022
88ee5e6
correct routes
stufisher Sep 27, 2022
538d418
fix tests
stufisher Sep 27, 2022
b66e9f6
correct schema
stufisher Sep 27, 2022
9b988c2
rename to with_authorization, add ProposalHasPerson
stufisher Sep 28, 2022
bfb90cc
uniquify uiGroups
stufisher Sep 28, 2022
b630908
return list of users beamlines
stufisher Sep 28, 2022
01e7e58
consistent naming
stufisher Sep 28, 2022
c783818
update docs
stufisher Sep 30, 2022
5507dde
add diffn thumbnail
stufisher Sep 30, 2022
2503b5b
group, optional
stufisher Oct 3, 2022
801c066
correct session and proposal queries, add duration to events
stufisher Oct 3, 2022
a1d7b42
lint
stufisher Oct 3, 2022
53706ab
order...
stufisher Oct 3, 2022
710716b
missing default
stufisher Oct 4, 2022
fe5bcdd
add xfe and energyscan base
stufisher Oct 4, 2022
b585170
temp remove RotationAxis enum
stufisher Oct 4, 2022
973b45d
correct some queries
stufisher Oct 5, 2022
65d02ae
add pia, some filters
stufisher Oct 6, 2022
856f742
bump models
stufisher Oct 10, 2022
3fb19b3
lint
stufisher Oct 10, 2022
49dd4b2
fix default sort
stufisher Oct 10, 2022
a9ffe29
improve performance (still work to do)
stufisher Oct 10, 2022
35af554
potentially empty dataset
stufisher Oct 10, 2022
7ed4941
conint
stufisher Oct 10, 2022
ea9c3cb
filters
stufisher Oct 10, 2022
e3e8cef
guard
stufisher Oct 10, 2022
98bb62f
distinct
stufisher Oct 10, 2022
f12fb6f
make sure there is always a condition applied
stufisher Oct 11, 2022
33cf738
join workflow, correct filters
stufisher Oct 11, 2022
56e9947
guard for missing table
stufisher Oct 11, 2022
f52521e
lint
stufisher Oct 11, 2022
5700bad
workflow steps, extra images
stufisher Oct 14, 2022
45dbbe2
update schemas
stufisher Oct 14, 2022
dfab96f
wip
stufisher Oct 15, 2022
6ce47dc
refactor
stufisher Oct 16, 2022
fc4849b
lint
stufisher Oct 16, 2022
e6bbbff
add auth options for tests
stufisher Oct 16, 2022
8016a35
add sessionId, proposalId where needed
stufisher Oct 19, 2022
26efd39
correct tests
stufisher Oct 19, 2022
955dc2d
minor fixes
stufisher Oct 20, 2022
0d9a28e
typo
stufisher Oct 20, 2022
8607968
check for file, change type
stufisher Oct 20, 2022
cdb03e7
minor fixes
stufisher Oct 24, 2022
f4264ac
missed refactorings
stufisher Oct 26, 2022
347cece
add sc type/capacity, make available to ui
stufisher Oct 26, 2022
29b59c6
style
stufisher Oct 26, 2022
36cdbb1
make patch work
stufisher Oct 26, 2022
5b0096c
add exception handler
stufisher Oct 26, 2022
4e4cd5b
dont clobber the existing model
stufisher Oct 26, 2022
8974fcf
handle enums
stufisher Oct 26, 2022
03cfaf4
add sample specific container model
stufisher Oct 26, 2022
47fff3b
lint...
stufisher Oct 26, 2022
d875d26
update models
stufisher Oct 26, 2022
6ff50a3
extract into get_options
stufisher Nov 3, 2022
e724261
improve tests
stufisher Nov 3, 2022
176c4a6
missed get_options, add test
stufisher Nov 3, 2022
679f817
more tests
stufisher Nov 3, 2022
fe5be3f
fix order
stufisher Nov 3, 2022
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
34 changes: 1 addition & 33 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# Authentication and authorization

## Authentication
# Authentication

`py-ispyb` relies on plugins to handle different methods of authenticating users to the system. There are some mechanisms that are implemented natively like LDAP, keycloak and dummy that can be used out-of-the-box. However, it is worth noting that anyone can write his own plugin.

Expand Down Expand Up @@ -189,33 +187,3 @@ class MyAuthentication(AbstractAuthentication):
...
```

### Authorization dependencies

The following dependencies can be used to manage authentication and authorization rules.

#### `permission_required(operator, [permissions])`

Makes the route only accessible to users with the **specified permissions**.

- `operator` is either
- `"any"` User should have **any** of the specified permissions
- `"all"` User should have **all** of the specified permissions

#### `proposal_authorisation`

Verifies that the user is **associated to the requested proposal**. To do so, it uses the `proposal_id` parameter.
User must verify any of the following conditions :

- `Person.personId = Proposal.personId`
- `Person.personId = ProposalHasPerson.personId and ProposalHasPerson.proposalId = Proposal.proposalId`
- _has permission_ `all_proposals`

#### `session_authorisation`

Verifies that the user is **associated to the requested session**. To do so, it uses the `session_id` parameter.
User must verify any of the following conditions :

- `Person.personId = Session_has_Person.personId and Session_has_Person.sessionId = BLSession.sessionId`
- `BLSession.proposalId = Proposal.proposalId and Person.personId = Proposal.personId`
- _has permission_ `all_sessions`
92 changes: 92 additions & 0 deletions docs/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Proposal(s), Session(s), and related entities

Authorization is applied to all user facing resources in py-ISPyB and different permissions are available to grant users and staff access to entities related to the core of ISPyB. These include but not limited to:

- Proposal
- Protein, Crystal, BLSample, Shipping, LabContact
- BLSession, DataCollectionGroup, DataCollection

etc ...

The authorization rules are applied in four ways:

### Users

- A user can access entities related to a Proposal and the DataCollection(s) in which they are a member of one or more Session(s) [linked via SessionHasPerson]. _This is an intrinsic permission and is the default behaviour if the user has no other permissions._
- A user can access entities related to all Session(s) in a Proposal [linked via ProposalHasPerson]

### Administrators

- An administrator can view all Sessions on a Proposal for specific beamline(s) via a `BeamLineGroup` permission
- An administrator can access all Sessions and Proposals via `all_proposals`

## BeamLineGroups

Beamline groups provide a way to grant access to all Proposals, Sessions and related entities to a set of staff members for a particular group of beamlines.

For example:

```json
"beamLineGroups": [
{
"groupName": "BL0x",
"uiGroup": "mx",
stufisher marked this conversation as resolved.
Show resolved Hide resolved
"permission": "bl0_admin",
"beamlines": [
stufisher marked this conversation as resolved.
Show resolved Hide resolved
{"beamLineName": "BL01"},
{"beamLineName": "BL02"},
],
},
]
```

A staff member with the `bl0_admin` permission will be able to access Proposal(s) and Session(s) allocated on beamlines `BL01` and `BL02`, but not other beamlines. `uiGroup` specifies how this group should be rendered in the UI.

# Permissions

Routes can require a specific permission by using the `permission` dependency.

```python
from pyispyb.dependencies import permission


@router.get(
"/path",
)
def get_something(depends: bool = Depends(permission("my_permission"))):
...
```

# Deprecated Authorization Mechanisms

These functions are deprecated and currently only used in the legacy API resources. They should not be used for new developments.

## Authorization dependencies

The following decorators can be used to manage authentication and authorization rules.

### `permission_required(operator, [permissions])`

Makes the route only accessible to users with the **specified permissions**.

- `operator` is either
- `"any"` User should have **any** of the specified permissions
- `"all"` User should have **all** of the specified permissions

### `proposal_authorisation`

Verifies that the user is **associated to the requested proposal**. To do so, it uses the `proposal_id` parameter.
User must verify any of the following conditions :

- `Person.personId = Proposal.personId`
- `Person.personId = ProposalHasPerson.personId and ProposalHasPerson.proposalId = Proposal.proposalId`
- _has permission_ `all_proposals`

### `session_authorisation`

Verifies that the user is **associated to the requested session**. To do so, it uses the `session_id` parameter.
User must verify any of the following conditions :

- `Person.personId = Session_has_Person.personId and Session_has_Person.sessionId = BLSession.sessionId`
- `BLSession.proposalId = Proposal.proposalId and Person.personId = Proposal.personId`
- _has permission_ `all_sessions`
7 changes: 4 additions & 3 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
site_name: py-ISPyB
nav:
- Home: index.md
- Get started: run.md
- Get Started: run.md
- Tests: tests.md
- Configuration: conf.md
- Authentication and authorization:
- Basics: auth.md
- Authentication and Authorization:
- Authentication: auth.md
- Authorization: authorization.md
- Permissions: permissions.md
- Routes:
- About: routes.md
Expand Down
135 changes: 103 additions & 32 deletions pyispyb/app/extensions/database/definitions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import Optional, Any

import sqlalchemy
Expand All @@ -7,6 +8,8 @@
from pyispyb.app.globals import g
from pyispyb.app.extensions.database.middleware import db

logger = logging.getLogger(__name__)

_session = sqlalchemy.func.concat(
models.Proposal.proposalCode,
models.Proposal.proposalNumber,
Expand All @@ -19,38 +22,6 @@
).label("proposal")


def get_blsession(session: str) -> Optional[models.BLSession]:
return (
db.session.query(models.BLSession)
.join(models.Proposal)
.filter(_session == session)
.first()
)


def with_auth_to_session(
query: "sqlalchemy.orm.Query[Any]", column: "sqlalchemy.Column[Any]"
) -> "sqlalchemy.orm.Query[Any]":
"""Join relevant tables to authorise right through to SessionHasPerson

in case of not being admin, can be reused"""
return (
query.join(models.Proposal, column == models.Proposal.proposalId)
.join(
models.BLSession, models.BLSession.proposalId == models.Proposal.proposalId
)
.join(
models.SessionHasPerson,
models.BLSession.sessionId == models.SessionHasPerson.sessionId,
)
.join(
models.Person,
models.SessionHasPerson.personId == models.Person.personId,
)
.filter(models.Person.login == g.login)
)


def get_current_person(login: str) -> Optional[models.Person]:
person = (
db.session.query(models.Person)
Expand All @@ -70,3 +41,103 @@ def get_current_person(login: str) -> Optional[models.Person]:
person._metadata["permissions"] = permissions

return person


def with_authorization(
query: "sqlalchemy.orm.Query[Any]",
includeArchived: bool = False,
proposalColumn: "sqlalchemy.Column[Any]" = None,
joinBLSession: bool = True,
stufisher marked this conversation as resolved.
Show resolved Hide resolved
) -> "sqlalchemy.orm.Query[Any]":
"""Apply authorization to a query

Checks in the following order:
* `all_proposals` allowing access to everything
* checks if the user is in a beamLineGroup to allow access to all proposals on a beamline
* checks ProposalHasPerson
* falls back to SessionHasPerson allowing access to entities related to where the
user is registered on a session

Kwargs:
includeArchived: whether to exclude archived beamlines
proposalColumn: the column used to join to `models.Proposal`, will force a join with `models.Proposal`
joinBLSession: whether to join `models.BLSession`
joinSessionHasPerson: whether to join `models.SessionHasPerson`
"""
# Avoid circular import
from pyispyb.app.main import app

# `all_proposals`` can access all sessions
if "all_proposals" in g.permissions:
logger.info("user has `all_proposals`")
return query

# Iterate through users permissions and match them to the relevant groups
beamLines = []
permissions_applied = []
for group in app.db_options.beamLineGroups:
if group.permission in g.permissions:
permissions_applied.append(group.permission)
for beamLine in group.beamLines:
if (beamLine.archived and includeArchived) or not includeArchived:
beamLines.append(beamLine.beamLineName)

if proposalColumn:
query = query.join(
models.Proposal, models.Proposal.proposalId == proposalColumn
)

if joinBLSession:
query = query.outerjoin(
models.BLSession, models.BLSession.proposalId == models.Proposal.proposalId
)

conditions = []
if beamLines:
logger.info(
f"filtered to beamlines `{beamLines}` with permissions `{permissions_applied}`"
)

conditions.append(models.BLSession.beamLineName.in_(beamLines))

# Sessions
sessions = db.session.query(models.SessionHasPerson.sessionId).filter(
models.SessionHasPerson.personId == g.personId
)
sessions = [r._asdict()["sessionId"] for r in sessions.all()]
conditions.append(models.BLSession.sessionId.in_(sessions if sessions else []))

# Proposals
proposals = db.session.query(models.ProposalHasPerson.proposalId).filter(
models.ProposalHasPerson.personId == g.personId
)
proposals = [r._asdict()["proposalId"] for r in proposals.all()]
conditions.append(models.Proposal.proposalId.in_(proposals if proposals else []))

query = query.filter(sqlalchemy.or_(*conditions))
return query


def groups_from_beamlines(beamLines: list[str]) -> list[list]:
"""Get uiGroups from a list of beamlines"""
from pyispyb.app.main import app

groups = []
for beamline in beamLines:
for group in app.db_options.beamLineGroups:
for groupBeamline in group.beamLines:
if beamline == groupBeamline.beamLineName:
groups.append(group.uiGroup)

return list(set(groups))


def beamlines_from_group(beamLineGroup: str) -> list[str]:
"""Get a list of beamlines from a groupName"""
from pyispyb.app.main import app

for group in app.db_options.beamLineGroups:
if group.groupName == beamLineGroup:
return [beamline.beamLineName for beamline in group.beamLines]

return []
47 changes: 30 additions & 17 deletions pyispyb/app/extensions/database/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import enum
import os
import time
import logging
Expand All @@ -17,45 +18,46 @@
def order(
query: "sqlalchemy.orm.Query[Any]",
sort_map: dict[str, "sqlalchemy.Column[Any]"],
order: str,
default: Optional[list[str]] = None,
order_by: Optional[str] = None,
order: Optional[dict[str]],
default: Optional[dict[str]] = None,
) -> "sqlalchemy.orm.Query[Any]":
"""Sort a result set by a field
"""Sort a result set by a column

Args:
query (sqlalchemy.query): The current query
sort_map (dict): A mapping of field(str) -> sqlalchemy.Column

Kwargs:
order_by (str): Field to sort by
order (Order): Asc or desc
order (dict): { order_by: column, order: Asc or desc }

Returns
query (sqlalchemy.orm.Query): The ordered query
"""
if not (order_by and order) and not default:
if not (order["order_by"] and order["order"]) and not default:
return query

logger.info(f"Ordering by {order['order_by']} {order['order']}")

if default:
order_by = default[0]
order = default[1]
return query.order_by(
getattr(sort_map[default["order_by"]], default["order"])()
)

if order_by not in sort_map:
logger.warning(f"Unknown order_by {order_by}")
if order["order_by"].value not in sort_map:
logger.warning(f"Unknown order_by {order['order_by']}")
return query

return query.order_by(getattr(sort_map[order_by], str(order))())
return query.order_by(
getattr(sort_map[order["order_by"].value], order["order"].value)()
)


def page(
query: "sqlalchemy.orm.Query[Any]", skip: int, limit: int
query: "sqlalchemy.orm.Query[Any]", *, skip: int, limit: int
) -> "sqlalchemy.orm.Query[Any]":
"""Paginate a `Query`

Kwargs:
per_page (str): Number of rows per page
page (str): Page number to display
skip (str): Offset to start at
limit(str): Number of items to display

Returns
query (sqlalchemy.orm.Query): The paginated query
Expand Down Expand Up @@ -101,6 +103,17 @@ def with_metadata(
return parsed


def update_model(model: any, values: dict[str, any]):
"""Update a model with new values including nested models"""
for key, value in values.items():
if isinstance(value, dict):
update_model(getattr(model, key), value)
else:
if isinstance(value, enum.Enum):
value = value.value
setattr(model, key, value)


ENABLE_DEBUG_LOGGING = False


Expand Down
Loading