Skip to content

Commit

Permalink
✨(api) add option to enforce scopes
Browse files Browse the repository at this point in the history
The current state of Ralph allows to restrict users by authority, but does not
allow a/An admin user b/Finer access control (read, write). This PR aims to
solve this issue by implementing `RESTRICT_BY_SCOPES` (`scopes` field is
already present in user accounts) which restricts access when enabled.
  • Loading branch information
Leobouloc committed Oct 24, 2023
1 parent 247be51 commit df62058
Show file tree
Hide file tree
Showing 18 changed files with 731 additions and 263 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ have an authority field matching that of the user
- CLI: list cli usage strings in alphabetical order
- Helm: Fix clickhouse version
- Helm: improve volumes and ingress configurations
- API: Add `RALPH_LRS_RESTRICT_BY_SCOPE` option enabling endpoint access
control by user scopes

### Fixed

Expand Down
24 changes: 22 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ By default, all authenticated users have full read and write access to the serve

### Filtering results by authority (multitenancy)

In Ralph, all incoming statements are assigned an `authority` (or ownership) derived from the user that makes the call. You may restrict read access to users "own" statements (thus enabling multitenancy) by setting the following environment variable:
In Ralph LRS, all incoming statements are assigned an `authority` (or ownership) derived from the user that makes the call. You may restrict read access to users "own" statements (thus enabling multitenancy) by setting the following environment variable:

```
RALPH_LRS_RESTRICT_BY_AUTHORITY = True # Default: False
Expand All @@ -190,7 +190,27 @@ NB: If not using "scopes", or for users with limited "scopes", using this option

#### Scopes

(Work In Progress)
In Ralph, users are assigned scopes which may be used to restrict endpoint access or
functionalities. You may enable this option by setting the following environment variable:

```
RALPH_LRS_RESTRICT_BY_SCOPES = True # Default: False
```

Valid scopes are a slight variation on those proposed by the
[xAPI specification](https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#details-15):


- statements/write
- statements/read/mine
- statements/read
- state/write
- state/read
- define
- profile/write
- profile/read
- all/read
- all

## Forwarding statements

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ match = ^(?!(setup)\.(py)$).*\.(py)$
[isort]
known_ralph=ralph
sections=FUTURE,STDLIB,THIRDPARTY,RALPH,FIRSTPARTY,LOCALFOLDER
skip_glob=venv
skip_glob=venv,*/.conda/*
profile=black

[tool:pytest]
Expand Down
13 changes: 6 additions & 7 deletions src/ralph/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
"""Main module for Ralph's LRS API authentication."""

from ralph.api.auth.basic import get_authenticated_user as get_basic_user
from ralph.api.auth.oidc import get_authenticated_user as get_oidc_user
from ralph.api.auth.basic import get_basic_auth_user
from ralph.api.auth.oidc import get_oidc_user
from ralph.conf import settings

# At startup, select the authentication mode that will be used
get_authenticated_user = (
get_oidc_user
if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC
else get_basic_user
)
if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC:
get_authenticated_user = get_oidc_user
else:
get_authenticated_user = get_basic_auth_user
28 changes: 20 additions & 8 deletions src/ralph/api/auth/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import bcrypt
from cachetools import TTLCache, cached
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.security import HTTPBasic, HTTPBasicCredentials, SecurityScopes
from pydantic import BaseModel, root_validator
from starlette.authentication import AuthenticationError

Expand Down Expand Up @@ -102,15 +102,17 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials:
@cached(
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
lock=Lock(),
key=lambda credentials: (
key=lambda credentials, security_scopes: (
credentials.username,
credentials.password,
security_scopes.scope_str,
)
if credentials is not None
else None,
)
def get_authenticated_user(
def get_basic_auth_user(
credentials: Union[HTTPBasicCredentials, None] = Depends(security),
security_scopes: SecurityScopes = SecurityScopes([]),
) -> AuthenticatedUser:
"""Checks valid auth parameters.
Expand All @@ -119,13 +121,10 @@ def get_authenticated_user(
Args:
credentials (iterator): auth parameters from the Authorization header
Return:
AuthenticatedUser (AuthenticatedUser)
security_scopes: scopes requested for access
Raises:
HTTPException
"""
if not credentials:
logger.error("The basic authentication mode requires a Basic Auth header")
Expand Down Expand Up @@ -156,6 +155,7 @@ def get_authenticated_user(
status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)
) from exc

# Check that a password was passed
if not hashed_password:
# We're doing a bogus password check anyway to avoid timing attacks on
# usernames
Expand All @@ -168,6 +168,7 @@ def get_authenticated_user(
headers={"WWW-Authenticate": "Basic"},
)

# Check password validity
if not bcrypt.checkpw(
credentials.password.encode(settings.LOCALE_ENCODING),
hashed_password.encode(settings.LOCALE_ENCODING),
Expand All @@ -182,4 +183,15 @@ def get_authenticated_user(
headers={"WWW-Authenticate": "Basic"},
)

return AuthenticatedUser(scopes=user.scopes, agent=user.agent)
user = AuthenticatedUser(scopes=user.scopes, agent=dict(user.agent))

# Restrict access by scopes
if settings.LRS_RESTRICT_BY_SCOPES:
for requested_scope in security_scopes.scopes:
if not user.scopes.is_authorized(requested_scope):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f'Access not authorized to scope: "{requested_scope}".',
headers={"WWW-Authenticate": "Basic"},
)
return user
30 changes: 22 additions & 8 deletions src/ralph/api/auth/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

import logging
from functools import lru_cache
from typing import Optional, Union
from typing import Optional

import requests
from fastapi import Depends, HTTPException, status
from fastapi.security import OpenIdConnect
from fastapi.security import OpenIdConnect, SecurityScopes
from jose import ExpiredSignatureError, JWTError, jwt
from jose.exceptions import JWTClaimsError
from pydantic import AnyUrl, BaseModel, Extra
from typing_extensions import Annotated

from ralph.api.auth.user import AuthenticatedUser
from ralph.api.auth.user import AuthenticatedUser, UserScopes
from ralph.conf import settings

OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration"
Expand Down Expand Up @@ -92,8 +93,9 @@ def get_public_keys(jwks_uri: AnyUrl) -> dict:
) from exc


def get_authenticated_user(
auth_header: Union[str, None] = Depends(oauth2_scheme)
def get_oidc_user(
auth_header: Annotated[Optional[str], Depends(oauth2_scheme)],
security_scopes: SecurityScopes = SecurityScopes([]),
) -> AuthenticatedUser:
"""Decode and validate OpenId Connect ID token against issuer in config.
Expand Down Expand Up @@ -143,7 +145,19 @@ def get_authenticated_user(

id_token = IDToken.parse_obj(decoded_token)

return AuthenticatedUser(
agent={"openid": id_token.sub},
scopes=id_token.scope.split(" ") if id_token.scope else [],
user = AuthenticatedUser(
agent={"openid": f"{id_token.iss}/{id_token.sub}"},
scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []),
)

# Restrict access by scopes
if settings.LRS_RESTRICT_BY_SCOPES:
for requested_scope in security_scopes.scopes:
if not user.scopes.is_authorized(requested_scope):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f'Access not authorized to scope: "{requested_scope}".',
headers={"WWW-Authenticate": "Basic"},
)

return user
49 changes: 47 additions & 2 deletions src/ralph/api/auth/user.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Authenticated user for the Ralph API."""

from typing import Dict, List, Literal
from functools import lru_cache
from typing import Dict, FrozenSet, Literal

from pydantic import BaseModel

Expand All @@ -18,6 +19,50 @@
]


class UserScopes(FrozenSet[Scope]):
"""Scopes available to users."""

@lru_cache(maxsize=1024)
def is_authorized(self, requested_scope: Scope):
"""Check if the requested scope can be accessed based on user scopes."""
expanded_scopes = {
"statements/read": {"statements/read/mine", "statements/read"},
"all/read": {
"statements/read/mine",
"statements/read",
"state/read",
"profile/read",
"all/read",
},
"all": {
"statements/write",
"statements/read/mine",
"statements/read",
"state/read",
"state/write",
"define",
"profile/read",
"profile/write",
"all/read",
"all",
},
}

expanded_user_scopes = set()
for scope in self:
expanded_user_scopes.update(expanded_scopes.get(scope, {scope}))

return requested_scope in expanded_user_scopes

@classmethod
def __get_validators__(cls): # noqa: D105
def validate(value: FrozenSet[Scope]):
"""Transform value to an instance of UserScopes."""
return cls(value)

yield validate


class AuthenticatedUser(BaseModel):
"""Pydantic model for user authentication.
Expand All @@ -27,4 +72,4 @@ class AuthenticatedUser(BaseModel):
"""

agent: Dict
scopes: List[Scope]
scopes: UserScopes
Loading

0 comments on commit df62058

Please sign in to comment.