Skip to content

Commit

Permalink
Beamline Groups & Filtering (#203)
Browse files Browse the repository at this point in the history
+ some basic resources
  • Loading branch information
stufisher authored Nov 3, 2022
1 parent b4e1f53 commit e748192
Show file tree
Hide file tree
Showing 71 changed files with 4,496 additions and 1,355 deletions.
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",
"permission": "bl0_admin",
"beamlines": [
{"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
140 changes: 108 additions & 32 deletions pyispyb/app/extensions/database/definitions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import logging
from typing import Optional, Any

import sqlalchemy
from sqlalchemy.orm import joinedload
from ispyb import models
from pyispyb.app.extensions.options.schema import Options

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 +23,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 +42,107 @@ def get_current_person(login: str) -> Optional[models.Person]:
person._metadata["permissions"] = permissions

return person


def get_options() -> Options:
"""Get db_options from app"""
# Avoid circular import
from pyispyb.app.main import app

return app.db_options


def with_authorization(
query: "sqlalchemy.orm.Query[Any]",
includeArchived: bool = False,
proposalColumn: "sqlalchemy.Column[Any]" = None,
joinBLSession: bool = True,
) -> "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`
"""
# `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 = []
db_options = get_options()
for group in 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"""
db_options = get_options()
groups = []
for beamline in beamLines:
for group in 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"""
db_options = get_options()
for group in db_options.beamLineGroups:
if group.groupName == beamLineGroup:
return [beamline.beamLineName for beamline in group.beamLines]

return []
Loading

0 comments on commit e748192

Please sign in to comment.